@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -180,13 +180,40 @@ function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[])
180
180
  : `[opencodex] Cursor's transport limit allows ${kept.length} of ${kept.length + omitted.length} client tools this turn. Omitted and unavailable this turn: ${omittedSummary}.`;
181
181
  }
182
182
 
183
+ /**
184
+ * True when this turn should take Cursor's fast variant.
185
+ *
186
+ * Reads the tier DECISION rather than the raw caller field so one authority owns precedence:
187
+ * `decideTier` has already applied config `fastMode`, the caller's `service_tier`, and the
188
+ * route's eligibility, so `fastMode: false` correctly suppresses a caller's Fast request.
189
+ * A `{kind:"set"}` decision on a Cursor route means canonical Fast survived that gate.
190
+ */
191
+ export function cursorFastRequested(parsed: OcxParsedRequest): boolean {
192
+ return parsed.options.tierDecision?.kind === "set";
193
+ }
194
+
195
+ /**
196
+ * Whether the wire this request will carry expresses the fast variant, for tier telemetry.
197
+ *
198
+ * Recomputed from the same pure inputs the builder uses rather than read off a built
199
+ * request: `tierLogForRunTurn` runs BEFORE `runTurn` (server/responses/core.ts), and
200
+ * `createCursorRequest` is not pure — it mints conversation ids — so rebuilding there would
201
+ * report a request that was never sent.
202
+ */
203
+ export function cursorRequestEmitsFastVariant(parsed: OcxParsedRequest): boolean {
204
+ if (!cursorFastRequested(parsed)) return false;
205
+ const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, true);
206
+ return model.modelId.endsWith("-fast")
207
+ || (model.requestedModelParameters ?? []).some(p => p.id === "fast" && p.value === "true");
208
+ }
209
+
183
210
  /**
184
211
  * Resolve a `cursor/<model>` selection + Codex reasoning effort to Cursor's requested model shape.
185
212
  * Most models encode effort in a flat id (`claude-4.6-opus-high`). Grok Fast is parameterized
186
213
  * instead: current Cursor clients send the matching Grok base id plus `effort` and `fast` parameters.
187
214
  * A fully-qualified id (one that is not a known effort base) passes through unchanged.
188
215
  */
189
- function normalizeCursorModelId(modelId: string, reasoning?: string): {
216
+ function normalizeCursorModelId(modelId: string, reasoning?: string, fast?: boolean): {
190
217
  modelId: string;
191
218
  requestedModelParameters?: readonly CursorRequestedModelParameter[];
192
219
  routingLevel?: CursorRoutingLevel;
@@ -201,7 +228,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
201
228
  const id = selection.modelId;
202
229
  // Grok Fast stays parameterized: current Cursor clients send the base id
203
230
  // plus effort/fast parameters instead of the flattened -fast id.
204
- const grokFast = cursorGrokFastSelection(id, reasoning);
231
+ const grokFast = cursorGrokFastSelection(id, reasoning, fast);
205
232
  if (grokFast) {
206
233
  return {
207
234
  ...selection,
@@ -212,7 +239,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): {
212
239
  ],
213
240
  };
214
241
  }
215
- const resolved = resolveCursorSelection(id, reasoning);
242
+ const resolved = resolveCursorSelection(id, reasoning, undefined, { fast });
216
243
  return {
217
244
  ...selection,
218
245
  ...(resolved.maxMode ? { maxMode: true } : {}),
@@ -455,7 +482,7 @@ export function createCursorRequest(
455
482
  const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice);
456
483
  const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice);
457
484
  const limitNote = catalogLimitNote(budget.tools, budget.omitted);
458
- const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning);
485
+ const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning, cursorFastRequested(parsed));
459
486
  const request: CursorRunRequest = {
460
487
  modelId: model.modelId,
461
488
  ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}),
@@ -12,6 +12,7 @@ import {
12
12
  cursorClientThreadOwner,
13
13
  cursorCoveredPrefixDigest,
14
14
  cursorInstructionDigest,
15
+ cursorRequestEmitsFastVariant,
15
16
  } from "./cursor/request-builder";
16
17
  import {
17
18
  createLiveCursorTransport,
@@ -26,6 +27,7 @@ import {
26
27
  invalidateCursorCheckpoint,
27
28
  } from "./cursor/checkpoint-store";
28
29
  import { debugProviderDiagnostic } from "../lib/debug";
30
+ import { createAdapterTierMetadata } from "../providers/fastwire";
29
31
  import { estimateTokens } from "../lib/token-estimate";
30
32
  import { rememberCursorThreadConversation } from "./cursor/thread-continuity";
31
33
  import { runCursorTurnWithRetry } from "./cursor/transport-retry";
@@ -100,6 +102,21 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
100
102
  return {
101
103
  name: "cursor",
102
104
 
105
+ // Cursor emits Fast as a model variant, so the generic "no field emitted" fallback in
106
+ // adapters/registry.ts would report every Fast turn as downgraded. This recomputes the
107
+ // variant from the same pure inputs the builder uses: tierLogForRunTurn runs BEFORE
108
+ // runTurn, and createCursorRequest mints conversation ids, so rebuilding it here would
109
+ // describe a request that was never sent.
110
+ tierLogForRunTurn(parsed) {
111
+ const fast = cursorRequestEmitsFastVariant(parsed);
112
+ return createAdapterTierMetadata(
113
+ parsed.options.tierObservation,
114
+ parsed.options.tierDecision,
115
+ fast ? "cursor-variant" : null,
116
+ fast ? "fast" : null,
117
+ );
118
+ },
119
+
103
120
  buildRequest() {
104
121
  return {
105
122
  url: provider.baseUrl || CURSOR_API_URL,
@@ -323,6 +323,39 @@ function reasoningTextFrom(record: Record<string, unknown>): string | undefined
323
323
  : undefined;
324
324
  }
325
325
 
326
+ interface ReasoningDetailSegment {
327
+ key: string;
328
+ text: string;
329
+ }
330
+
331
+ /**
332
+ * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`).
333
+ * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the
334
+ * full text-so-far under a stable `id`/`index` instead of sending increments.
335
+ */
336
+ function reasoningDetailSegmentsFrom(record: Record<string, unknown>): ReasoningDetailSegment[] {
337
+ const raw = record.reasoning_details;
338
+ if (!Array.isArray(raw)) return [];
339
+ const segments: ReasoningDetailSegment[] = [];
340
+ for (let i = 0; i < raw.length; i++) {
341
+ const item: unknown = raw[i];
342
+ if (!isRecord(item)) continue;
343
+ if (typeof item.text !== "string" || item.text.length === 0) continue;
344
+ const key = typeof item.id === "string" && item.id.length > 0
345
+ ? `id:${item.id}`
346
+ : typeof item.index === "number"
347
+ ? `i:${item.index}`
348
+ : `n:${i}`;
349
+ segments.push({ key, text: item.text });
350
+ }
351
+ return segments;
352
+ }
353
+
354
+ /** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */
355
+ function reasoningDetailSegmentForWire(text: string): Record<string, unknown> {
356
+ return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text };
357
+ }
358
+
326
359
  function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
327
360
  return {
328
361
  type: "error",
@@ -766,9 +799,17 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
766
799
  }
767
800
  }
768
801
  if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
769
- chatMsg.reasoning_content = reasoningContent;
802
+ // MiniMax's interleaved-thinking contract requires the structured
803
+ // reasoning_details array back on the next turn; a reasoning_content
804
+ // string is the native-format pass-back the docs mark unsupported.
805
+ if (modelInList(provider.reasoningDetailsModels, parsed.modelId)) {
806
+ chatMsg.reasoning_details = [reasoningDetailSegmentForWire(reasoningContent)];
807
+ } else {
808
+ chatMsg.reasoning_content = reasoningContent;
809
+ }
770
810
  }
771
- if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
811
+ const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined;
812
+ if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break;
772
813
  flushPendingToolCalls();
773
814
  const wireToolCalls = toolCalls.map(tc => {
774
815
  let id = tc.id;
@@ -784,7 +825,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
784
825
  }));
785
826
  if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
786
827
  }
787
- if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
828
+ if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
788
829
  chatMsg.content = emptyAssistantContent(provider);
789
830
  }
790
831
  out.push(chatMsg);
@@ -829,10 +870,15 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
829
870
  && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
830
871
  ? " "
831
872
  : undefined);
873
+ const orphanReasoningFields: Record<string, unknown> = !orphanReasoning
874
+ ? {}
875
+ : modelInList(provider.reasoningDetailsModels, parsed.modelId)
876
+ ? { reasoning_details: [reasoningDetailSegmentForWire(orphanReasoning)] }
877
+ : { reasoning_content: orphanReasoning };
832
878
  out.push({
833
879
  role: "assistant",
834
880
  content: emptyAssistantContent(provider),
835
- ...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}),
881
+ ...orphanReasoningFields,
836
882
  tool_calls: [{
837
883
  id: toolCallId,
838
884
  type: "function",
@@ -1393,12 +1439,14 @@ function canSerializeOpenAIChatServiceTier(
1393
1439
  }
1394
1440
 
1395
1441
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
1442
+ let lastRequestedModelId: string | undefined;
1396
1443
  return {
1397
1444
  name: "openai-chat",
1398
1445
 
1399
1446
  formatErrorBody: formatOpenAIChatErrorBody,
1400
1447
 
1401
1448
  buildRequest(parsed: OcxParsedRequest) {
1449
+ lastRequestedModelId = parsed.modelId;
1402
1450
  const { url, headers, hasCredential } = openAIChatTransport(provider);
1403
1451
  const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider));
1404
1452
  const tools = toolsToChatFormatForProvider(parsed, provider);
@@ -1444,10 +1492,18 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1444
1492
  }
1445
1493
  if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
1446
1494
  const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
1447
- const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
1495
+ // Some gateways accept a reasoning-effort field on a plain turn but reject the
1496
+ // effort + tools combination. `noReasoningModels` would fix that only by
1497
+ // stripping reasoning everywhere, costing the model its whole picker. This keeps
1498
+ // the ladder advertised and drops the wire field for tool-bearing requests only.
1499
+ const omitReasoningEffortWithTools = !!tools
1500
+ && modelInList(provider.omitReasoningEffortWithToolsModels, parsed.modelId);
1501
+ const reasoningEffort = omitReasoningEffortWithTools
1502
+ ? undefined
1503
+ : mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
1448
1504
  const nativeOpenAI = isNativeOpenAIChatTarget(provider);
1449
1505
  let reasoningLog: AdapterRequest["reasoningLog"];
1450
- if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
1506
+ if (!reasoningDisabled && !omitReasoningEffortWithTools && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
1451
1507
  if (nativeOpenAI) {
1452
1508
  body.reasoning_effort = "none";
1453
1509
  reasoningLog = {
@@ -1666,6 +1722,14 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1666
1722
  let pendingUsage: OcxUsage | undefined;
1667
1723
  let finishReason: string | undefined;
1668
1724
  let sawUserFacingOutput = false;
1725
+ // MiniMax-style structured reasoning: each stream chunk repeats a detail's
1726
+ // full text-so-far, so deltas are derived by prefix-diffing per segment key.
1727
+ // A piece that does not extend the previous snapshot is appended whole, which
1728
+ // keeps incremental senders parseable on the same path.
1729
+ const reasoningDetailSnapshots = new Map<string, string>();
1730
+ // Gate on the routed model, not list length: a mixed openai-chat provider
1731
+ // can list MiniMax ids without putting every sibling on MiniMax semantics.
1732
+ const reasoningDetailsOptIn = modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? "");
1669
1733
 
1670
1734
  const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
1671
1735
  const rawPayload = sseFieldValue(line, "data");
@@ -1722,8 +1786,23 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1722
1786
  if (typeof choice.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason;
1723
1787
  const delta = choice.delta;
1724
1788
  if (delta) {
1725
- const reasoningText = reasoningTextFrom(delta);
1726
- if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
1789
+ const detailSegments = reasoningDetailsOptIn ? reasoningDetailSegmentsFrom(delta) : [];
1790
+ if (detailSegments.length > 0) {
1791
+ for (const segment of detailSegments) {
1792
+ const prev = reasoningDetailSnapshots.get(segment.key) ?? "";
1793
+ if (segment.text === prev) continue;
1794
+ if (segment.text.startsWith(prev)) {
1795
+ reasoningDetailSnapshots.set(segment.key, segment.text);
1796
+ yield { type: "reasoning_raw_delta", text: segment.text.slice(prev.length) };
1797
+ } else {
1798
+ reasoningDetailSnapshots.set(segment.key, prev + segment.text);
1799
+ yield { type: "reasoning_raw_delta", text: segment.text };
1800
+ }
1801
+ }
1802
+ } else {
1803
+ const reasoningText = reasoningTextFrom(delta);
1804
+ if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
1805
+ }
1727
1806
  if (typeof delta.content === "string" && delta.content.length > 0) {
1728
1807
  sawUserFacingOutput = true;
1729
1808
  yield { type: "text_delta", text: delta.content };
@@ -2015,7 +2094,14 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
2015
2094
  }
2016
2095
 
2017
2096
  const msg = rawMessage as Record<string, unknown>;
2018
- const reasoningText = reasoningTextFrom(msg);
2097
+ let reasoningText = reasoningTextFrom(msg);
2098
+ if (reasoningText === undefined && modelInList(provider.reasoningDetailsModels, lastRequestedModelId ?? "")) {
2099
+ // MiniMax split-reasoning responses carry the same thinking in both
2100
+ // reasoning_content and reasoning_details; the array is the fallback
2101
+ // when only the structured form arrives.
2102
+ const segments = reasoningDetailSegmentsFrom(msg);
2103
+ if (segments.length > 0) reasoningText = segments.map(s => s.text).join("");
2104
+ }
2019
2105
  if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
2020
2106
  if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
2021
2107
  const rawToolCalls = msg.tool_calls;
@@ -467,7 +467,12 @@ function normalizeConfiguredReasoningSummaryDelivery(
467
467
  * namespace, tool_search, web_search, custom) plus extensions (defer_loading,
468
468
  * parallel_tool_calls, tool_search_call/output items). Spark's serving path only
469
469
  * supports flat function tools and hosted web_search. This function:
470
- * - Flattens namespace tools → promotes inner functions to top level
470
+ * - Flattens MCP-style namespace tools → promotes inner functions to top level. The reserved
471
+ * `functions` group is kept as a group (#3217): Codex 0.147+ sends every ordinary client tool
472
+ * inside it on Responses Lite, the backend accepts the group as-is, and flattening it changes
473
+ * what the backend answers with — a `custom_tool_call` carrying `namespace: "exec"`, which
474
+ * codex-rs concatenates into the unroutable `execexec`. Traced on a live proxy: with the
475
+ * group intact the same backend returns the bare `exec` call and the turn completes.
471
476
  * - Drops unsupported tool types (tool_search, custom)
472
477
  * - Strips defer_loading from function tools
473
478
  * - Strips namespace from input items
@@ -482,12 +487,39 @@ function stripSparkCompatibility(body: unknown): unknown {
482
487
  let changed = false;
483
488
 
484
489
  const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]);
490
+ // Inside the reserved group Codex sends freeform `custom` tools (code-mode `exec`) and the
491
+ // backend accepts them there; the top-level "drop custom" rule stays for flattened groups.
492
+ const SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES = new Set(["function", "custom"]);
493
+ const filterSparkFunctionsGroup = (group: Record<string, unknown>): Record<string, unknown> | undefined => {
494
+ if (!Array.isArray(group.tools)) return undefined;
495
+ let groupChanged = false;
496
+ const children: unknown[] = [];
497
+ for (const child of group.tools) {
498
+ if (!isPlainObject(child) || typeof child.type !== "string" || !SPARK_SAFE_FUNCTIONS_GROUP_CHILD_TYPES.has(child.type)) {
499
+ groupChanged = true;
500
+ continue;
501
+ }
502
+ if (child.type === "function" && "defer_loading" in child) {
503
+ const { defer_loading: _, ...rest } = child;
504
+ groupChanged = true;
505
+ children.push(rest);
506
+ continue;
507
+ }
508
+ children.push(child);
509
+ }
510
+ if (children.length === 0) return undefined;
511
+ return groupChanged ? { ...group, tools: children } : group;
512
+ };
485
513
 
486
514
  let tools = body.tools;
487
515
  if (Array.isArray(tools)) {
488
516
  const flattened: unknown[] = [];
489
517
  for (const t of tools) {
490
- if (isPlainObject(t) && t.type === "namespace") {
518
+ if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) {
519
+ const kept = filterSparkFunctionsGroup(t);
520
+ if (kept !== t) changed = true;
521
+ if (kept) flattened.push(kept);
522
+ } else if (isPlainObject(t) && t.type === "namespace") {
491
523
  changed = true;
492
524
  if (Array.isArray(t.tools)) {
493
525
  for (const inner of t.tools) flattened.push(inner);
@@ -527,7 +559,11 @@ function stripSparkCompatibility(body: unknown): unknown {
527
559
  const innerTools = item.tools as unknown[];
528
560
  const filteredInner: unknown[] = [];
529
561
  for (const t of innerTools) {
530
- if (isPlainObject(t) && t.type === "namespace") {
562
+ if (isPlainObject(t) && t.type === "namespace" && t.name === SPARK_RESERVED_FUNCTIONS_NAMESPACE) {
563
+ const kept = filterSparkFunctionsGroup(t);
564
+ if (kept !== t) changed = true;
565
+ if (kept) filteredInner.push(kept);
566
+ } else if (isPlainObject(t) && t.type === "namespace") {
531
567
  changed = true;
532
568
  if (Array.isArray(t.tools)) {
533
569
  for (const fn of t.tools) filteredInner.push(fn);
@@ -574,6 +610,9 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
574
610
  return !!v && typeof v === "object" && !Array.isArray(v);
575
611
  }
576
612
 
613
+ /** Codex's reserved client-tool group on Responses Lite; carries no wire prefix. */
614
+ const SPARK_RESERVED_FUNCTIONS_NAMESPACE = "functions";
615
+
577
616
  /**
578
617
  * Apply the routed provider's real effort ladder to an existing Responses reasoning field.
579
618
  * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay
@@ -2286,6 +2325,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2286
2325
  let doneText = "";
2287
2326
  let snapshot = "";
2288
2327
  let usage: OcxUsage | undefined;
2328
+ let compactionEncryptedContent: string | undefined;
2289
2329
  for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) {
2290
2330
  let payload: unknown;
2291
2331
  try { payload = JSON.parse(event.data); } catch { continue; }
@@ -2320,6 +2360,17 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2320
2360
  return;
2321
2361
  case "response.completed":
2322
2362
  {
2363
+ const responsePayload = isPlainObject(payload.response) ? payload.response : undefined;
2364
+ const output = Array.isArray(responsePayload?.output) ? responsePayload.output : [];
2365
+ const compaction = output.find(item => isPlainObject(item) && item.type === "compaction");
2366
+ if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") {
2367
+ const nextEncryptedContent = compaction.encrypted_content;
2368
+ const previousBytes = budgetEncoder.encode(compactionEncryptedContent ?? "").byteLength;
2369
+ const reservation = budget.reserveTransient(budgetEncoder.encode(nextEncryptedContent).byteLength, { kind: "retained_collectors" });
2370
+ compactionEncryptedContent = nextEncryptedContent;
2371
+ reservation.commitRetained();
2372
+ budget.releaseRetained(previousBytes, { kind: "retained_collectors" });
2373
+ }
2323
2374
  const next = responsesPayloadText(payload.response);
2324
2375
  const previousBytes = budgetEncoder.encode(snapshot).byteLength;
2325
2376
  const reservation = budget.reserveTransient(budgetEncoder.encode(next).byteLength, { kind: "retained_collectors" });
@@ -2336,7 +2387,11 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2336
2387
  const text = snapshot || doneText || deltas;
2337
2388
  if (text) yield { type: "text_delta", text };
2338
2389
  budget.releaseRetained(budgetEncoder.encode(deltas).byteLength + budgetEncoder.encode(doneText).byteLength + budgetEncoder.encode(snapshot).byteLength, { kind: "retained_collectors" });
2339
- yield { type: "done", ...(usage ? { usage } : {}) };
2390
+ yield {
2391
+ type: "done",
2392
+ ...(usage ? { usage } : {}),
2393
+ ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}),
2394
+ };
2340
2395
  },
2341
2396
 
2342
2397
  async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
@@ -2354,14 +2409,23 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2354
2409
  if (payload.status === "incomplete") {
2355
2410
  return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
2356
2411
  }
2412
+ const usage = usageFromResponsesPayload(payload);
2413
+ const output = Array.isArray(payload.output) ? payload.output : [];
2414
+ const compaction = output.find(item => isPlainObject(item) && item.type === "compaction");
2415
+ const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string"
2416
+ ? compaction.encrypted_content
2417
+ : undefined;
2357
2418
  const text = responsesPayloadText(payload);
2358
- if (!text) {
2359
- // A completed turn with no usable text cannot become a summary; saying so is
2360
- // better than installing an empty compaction as replacement history.
2419
+ if (!text && !compactionEncryptedContent) {
2420
+ // A completed turn with neither text nor a native compaction blob cannot become a
2421
+ // replacement-history item. A ciphertext-only native completion is valid, though.
2361
2422
  return [{ type: "error", message: "upstream compaction returned no summary text" }];
2362
2423
  }
2363
- const usage = usageFromResponsesPayload(payload);
2364
- return [{ type: "text_delta", text }, { type: "done", ...(usage ? { usage } : {}) }];
2424
+ return [...(text ? [{ type: "text_delta" as const, text }] : []), {
2425
+ type: "done",
2426
+ ...(usage ? { usage } : {}),
2427
+ ...(compactionEncryptedContent ? { compactionEncryptedContent } : {}),
2428
+ }];
2365
2429
  },
2366
2430
  };
2367
2431
  }
package/src/bridge.ts CHANGED
@@ -28,6 +28,11 @@ import {
28
28
  awaitThoughtSignatureDurability,
29
29
  } from "./responses/thought-signature-replay";
30
30
  import { resolveStallTimeoutSec } from "./stall-timeout";
31
+ import {
32
+ createCitationMarkerFilter,
33
+ stripCitationMarkers,
34
+ type CitationMarkerFilter,
35
+ } from "./responses/citation-markers";
31
36
  import { normalizeDeclaredToolName } from "./types";
32
37
  import { usageDisplayTotalTokens } from "./usage/totals";
33
38
  import { appendSafeWebSearchSource, safeWebSearchSources } from "./web-search/sources";
@@ -435,7 +440,14 @@ export function bridgeToResponsesSSE(
435
440
  const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec);
436
441
  const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs);
437
442
 
438
- let currentMsg: { itemId: string; outputIndex: number; text: string; textBytes: number; phase?: OcxMessagePhase } | null = null;
443
+ let currentMsg: {
444
+ itemId: string;
445
+ outputIndex: number;
446
+ text: string;
447
+ textBytes: number;
448
+ citationFilter: CitationMarkerFilter;
449
+ phase?: OcxMessagePhase;
450
+ } | null = null;
439
451
  let currentReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null;
440
452
  let currentRawReasoning: { itemId: string; outputIndex: number; text: string; textBytes: number } | null = null;
441
453
  // Anthropic extended-thinking round-trip state: the signature signs the CURRENT thinking
@@ -565,6 +577,17 @@ export function bridgeToResponsesSSE(
565
577
 
566
578
  const closeCurrentMessage = (inferredPhase?: OcxMessagePhase) => {
567
579
  if (!currentMsg) return;
580
+ // Release anything the citation filter was holding for this message, then strip the
581
+ // accumulated text: closeCurrentMessage re-sends it in output_text.done and
582
+ // output_item.done, so filtering only the deltas would leave the markers in both.
583
+ const trailing = currentMsg.citationFilter.flush();
584
+ if (trailing) {
585
+ emit("response.output_text.delta", {
586
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
587
+ content_index: 0, delta: trailing,
588
+ });
589
+ }
590
+ const messageText = stripCitationMarkers(currentMsg.text);
568
591
  // Chat Completions has no message-phase field. Keep its live item provisional, then
569
592
  // classify it only when the next adapter event proves whether this text led into more
570
593
  // work or completed the turn. Explicit adapter phases always outrank this inference.
@@ -574,15 +597,15 @@ export function bridgeToResponsesSSE(
574
597
  // Finalize the text part (Responses protocol). Without these .done events Codex never
575
598
  // commits the content part and renders the message as truncated / cut off.
576
599
  emit("response.output_text.done", {
577
- item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0, text: currentMsg.text,
600
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0, text: messageText,
578
601
  });
579
602
  emit("response.content_part.done", {
580
603
  item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0,
581
- part: { type: "output_text", text: currentMsg.text, annotations },
604
+ part: { type: "output_text", text: messageText, annotations },
582
605
  });
583
606
  const item = {
584
607
  type: "message", id: currentMsg.itemId, status: "completed", role: "assistant",
585
- content: [{ type: "output_text", text: currentMsg.text, annotations }],
608
+ content: [{ type: "output_text", text: messageText, annotations }],
586
609
  ...(phase ? { phase } : {}),
587
610
  };
588
611
  emit("response.output_item.done", { output_index: currentMsg.outputIndex, item });
@@ -936,7 +959,11 @@ export function bridgeToResponsesSSE(
936
959
  item_id: itemId, output_index: outputIndex, content_index: 0,
937
960
  part: { type: "output_text", text: "", annotations: [] },
938
961
  });
939
- currentMsg = { itemId, outputIndex, text: "", textBytes: 0, ...(event.phase ? { phase: event.phase } : {}) };
962
+ currentMsg = {
963
+ itemId, outputIndex, text: "", textBytes: 0,
964
+ citationFilter: createCitationMarkerFilter(),
965
+ ...(event.phase ? { phase: event.phase } : {}),
966
+ };
940
967
  }
941
968
  ({ value: currentMsg.text, bytes: currentMsg.textBytes } = appendString(
942
969
  currentMsg.text,
@@ -944,10 +971,16 @@ export function bridgeToResponsesSSE(
944
971
  event.text,
945
972
  "retained_collectors",
946
973
  ));
947
- emit("response.output_text.delta", {
948
- item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
949
- content_index: 0, delta: event.text,
950
- });
974
+ // A citation span can straddle a delta boundary, so the filter withholds an
975
+ // unterminated tail and releases it at close (#3150). The accumulator above
976
+ // keeps the raw text; it is stripped once in closeCurrentMessage.
977
+ const visible = currentMsg.citationFilter.push(event.text);
978
+ if (visible) {
979
+ emit("response.output_text.delta", {
980
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
981
+ content_index: 0, delta: visible,
982
+ });
983
+ }
951
984
  break;
952
985
  }
953
986
  case "thinking_delta": {
@@ -1223,10 +1256,12 @@ export function bridgeToResponsesSSE(
1223
1256
  // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0.
1224
1257
  const item = {
1225
1258
  type: "compaction", id: `cmp_${uuid()}`,
1226
- encrypted_content: encodeCompactionSummary(compactionText),
1259
+ encrypted_content: event.compactionEncryptedContent ?? encodeCompactionSummary(compactionText),
1227
1260
  };
1228
1261
  emit("response.output_item.done", { output_index: outputIndex, item });
1229
- retainFinishedItem(item as OutputItem, compactionTextBytes);
1262
+ retainFinishedItem(item as OutputItem, event.compactionEncryptedContent
1263
+ ? bytesOf(event.compactionEncryptedContent)
1264
+ : compactionTextBytes);
1230
1265
  outputIndex++;
1231
1266
  }
1232
1267
  // Recognize every adapter's truncation vocabulary, not just the canonical pair.
@@ -1574,6 +1609,7 @@ function buildResponseJSONWithBudget(
1574
1609
  let sawTerminal = false;
1575
1610
  let compactionText = "";
1576
1611
  let compactionTextBytes = 0;
1612
+ let compactionEncryptedContent: string | undefined;
1577
1613
 
1578
1614
  let currentText = "";
1579
1615
  let currentTextBytes = 0;
@@ -1618,6 +1654,10 @@ function buildResponseJSONWithBudget(
1618
1654
  const flushText = (inferredPhase?: OcxMessagePhase) => {
1619
1655
  if (!currentText) return;
1620
1656
  const phase = currentTextPhase ?? inferredPhase;
1657
+ // ChatGPT-backend citation markers arrive as literal private-use characters that the
1658
+ // Codex TUI prints verbatim (#3150). Strip them here rather than at the accumulator so
1659
+ // the retained byte accounting above still describes what the upstream actually sent.
1660
+ const text = stripCitationMarkers(currentText);
1621
1661
  const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
1622
1662
  const annotations = pendingWebSources.map(s => ({
1623
1663
  type: "url_citation", url: s.url, ...(s.title ? { title: s.title } : {}), start_index: 0, end_index: 0,
@@ -1625,7 +1665,7 @@ function buildResponseJSONWithBudget(
1625
1665
  pendingWebSources = [];
1626
1666
  const item = {
1627
1667
  type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
1628
- content: [{ type: "output_text", text: currentText, annotations }],
1668
+ content: [{ type: "output_text", text, annotations }],
1629
1669
  ...(phase ? { phase } : {}),
1630
1670
  } as OutputItem;
1631
1671
  pushOutput(item, currentTextBytes);
@@ -1915,6 +1955,7 @@ function buildResponseJSONWithBudget(
1915
1955
  break;
1916
1956
  case "done":
1917
1957
  usage = e.usage;
1958
+ compactionEncryptedContent = e.compactionEncryptedContent;
1918
1959
  sawTerminal = true;
1919
1960
  endTurn = e.endTurn;
1920
1961
  cleanDone = e.stopReason === undefined;
@@ -1967,7 +2008,11 @@ function buildResponseJSONWithBudget(
1967
2008
  && sawTerminal
1968
2009
  && !isTruncatedStopReason(rawStopReason)
1969
2010
  ) {
1970
- pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes);
2011
+ const item = {
2012
+ type: "compaction", id: `cmp_${uuid()}`,
2013
+ encrypted_content: compactionEncryptedContent ?? encodeCompactionSummary(compactionText),
2014
+ };
2015
+ pushOutput(item, compactionEncryptedContent ? bytesOf(compactionEncryptedContent) : compactionTextBytes);
1971
2016
  }
1972
2017
 
1973
2018
  const failure = errorEvent ? adapterFailureFromEvent(errorEvent) : undefined;
@@ -1,14 +1,11 @@
1
1
  /**
2
2
  * Claude auth-mode resolution.
3
3
  *
4
- * The resolver answers exactly ONE question: does the opencodex-owned dummy token
5
- * (`ANTHROPIC_AUTH_TOKEN=opencodex-proxy`) get injected? That is narrower than "how
6
- * will Claude authenticate" native passthrough additionally needs an `sk-ant-`
7
- * credential on the incoming request so the field is `markerMode`, not
8
- * `effectiveAuthMode` (devlog/_plan/260726_claude_auth_auto/002 R2-1).
9
- *
10
- * The admission-key axis is separate and untouched: when the proxy requires an
11
- * admission key, `buildClaudeEnv` injects it regardless of mode.
4
+ * The resolver answers which authentication mode the Claude launcher should honor.
5
+ * Native passthrough additionally needs an `sk-ant-` credential on the incoming request,
6
+ * so the field is `markerMode`, not `effectiveAuthMode` (devlog/_plan/260726_claude_auth_auto/002
7
+ * R2-1). The launchers use subscription mode to keep proxy-owned marker and admission
8
+ * credentials out of Claude's environment; proxy mode may inject them for gateway auth.
12
9
  */
13
10
  import type { OcxConfig } from "../types";
14
11
  import type { AuthDetectResult, AuthSourceId } from "./auth-detect";
@@ -18,7 +15,7 @@ export type MarkerMode = "proxy" | "subscription";
18
15
  export type AuthModeOrigin = "manual" | "auto-present" | "auto-absent" | "auto-unknown";
19
16
 
20
17
  export interface ResolvedAuthMode {
21
- /** Does the owned dummy marker get injected. NOT a claim about native auth. */
18
+ /** Proxy-owned auth mode for launchers. NOT a claim about native auth. */
22
19
  markerMode: MarkerMode;
23
20
  origin: AuthModeOrigin;
24
21
  /** The detector source that proved presence (origin auto-present only). */