@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -8,10 +8,12 @@ import { isDebugEnabled } from "../lib/debug-settings";
8
8
  import { isCyberPolicyCode } from "../lib/errors";
9
9
  import { redactSecretString } from "../lib/redact";
10
10
  import { contentPartsToText } from "./image";
11
+ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
11
12
  import { identifyRoutedModel } from "./identity";
12
13
  import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
13
14
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
14
15
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
16
+ import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing";
15
17
  import {
16
18
  canForwardForeignServiceTierForChatModel,
17
19
  fastPolicyForModel,
@@ -26,6 +28,7 @@ import {
26
28
  } from "../providers/fastwire";
27
29
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
28
30
  import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
31
+ import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter";
29
32
  import {
30
33
  isXaiSchemaTarget,
31
34
  lookupLocalJsonPointer,
@@ -87,7 +90,10 @@ function openAIChatTransport(provider: OcxProviderConfig): {
87
90
  if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
88
91
  throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
89
92
  }
90
- const headers: Record<string, string> = { "Content-Type": "application/json" };
93
+ const headers: Record<string, string> = {
94
+ "Content-Type": "application/json",
95
+ ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers),
96
+ };
91
97
  if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
92
98
  if (provider.headers) Object.assign(headers, provider.headers);
93
99
  return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
@@ -111,7 +117,7 @@ export function buildOpenAIChatPassthroughRequest(
111
117
 
112
118
  const body: Record<string, unknown> = {
113
119
  model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
114
- messages: rawBody.messages,
120
+ messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
115
121
  stream,
116
122
  };
117
123
  for (const field of CHAT_PASSTHROUGH_FIELDS) {
@@ -120,6 +126,8 @@ export function buildOpenAIChatPassthroughRequest(
120
126
 
121
127
  const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
122
128
  if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
129
+ const vercelRouting = resolveVercelGatewayRouting(provider, modelId);
130
+ if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
123
131
 
124
132
  if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
125
133
  if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
@@ -588,9 +596,22 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
588
596
  * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
589
597
  * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
590
598
  */
591
- function toolResultTextForWire(content: string | OcxContentPart[]): string {
592
- if (typeof content === "string") return content;
599
+ function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
600
+ // An empty content array is a present-but-empty result; `contentPartsToText` would
601
+ // otherwise fall back to the "[image]" marker and hide the emptiness from the model.
602
+ if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION;
603
+ if (typeof content === "string") {
604
+ if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION;
605
+ return content;
606
+ }
593
607
  const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
608
+ // A whitespace-only text-part array is the array twin of a blank string; the
609
+ // shared emptiness contract (same module as the Responses adapter) annotates it
610
+ // instead of forwarding whitespace the model silently accepts. Image parts and
611
+ // any other non-text part keep the array non-empty.
612
+ if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) {
613
+ return EMPTY_TOOL_OUTPUT_ANNOTATION;
614
+ }
594
615
  if (text) {
595
616
  const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
596
617
  return `${text}${"[image]".repeat(untransportableImages)}`;
@@ -777,7 +798,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
777
798
  out.push({
778
799
  role: "tool",
779
800
  tool_call_id: toolCallId,
780
- content: toolResultTextForWire(msg.content),
801
+ content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
781
802
  });
782
803
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
783
804
  pendingToolCalls.splice(matchIdx, 1);
@@ -822,7 +843,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
822
843
  out.push({
823
844
  role: "tool",
824
845
  tool_call_id: toolCallId,
825
- content: toolResultTextForWire(msg.content),
846
+ content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
826
847
  });
827
848
  pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
828
849
  flushToolResultImages();
@@ -1379,7 +1400,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1379
1400
 
1380
1401
  buildRequest(parsed: OcxParsedRequest) {
1381
1402
  const { url, headers, hasCredential } = openAIChatTransport(provider);
1382
- const messages = messagesToChatFormat(parsed, provider);
1403
+ const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider));
1383
1404
  const tools = toolsToChatFormatForProvider(parsed, provider);
1384
1405
  const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
1385
1406
 
@@ -1406,6 +1427,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1406
1427
  const maxTokens = resolveMaxTokens(provider, parsed);
1407
1428
  const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
1408
1429
  if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
1430
+ const vercelRouting = resolveVercelGatewayRouting(provider, parsed.modelId);
1431
+ if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
1409
1432
  if (tools) body.tools = tools;
1410
1433
  if (tools && toolChoice !== undefined) {
1411
1434
  body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)
@@ -14,13 +14,14 @@ import {
14
14
  isOpenAiOperatedResponsesDestination,
15
15
  } from "../providers/openai-tiers";
16
16
  import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
17
- import { modelRecordValue } from "../reasoning-effort";
17
+ import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
18
18
  import type { TranslatorBudget } from "../lib/translator-budget";
19
19
  import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
20
20
  import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
21
21
  import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
22
22
  import { openaiResponsesUrl } from "./openai-responses-url";
23
- import { normalizeXaiResponsesWebSearch } from "./xai-web-search";
23
+ import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search";
24
+ import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
24
25
  import {
25
26
  isXaiSchemaTarget,
26
27
  normalizeXaiToolParameters,
@@ -549,6 +550,27 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
549
550
  return !!v && typeof v === "object" && !Array.isArray(v);
550
551
  }
551
552
 
553
+ /**
554
+ * Apply the routed provider's real effort ladder to an existing Responses reasoning field.
555
+ * Native forward requests keep the server-owned native clamp; unknown third-party ladders stay
556
+ * byte-equivalent instead of acquiring a policy from this adapter.
557
+ */
558
+ function mapRoutedResponsesReasoningEffort(
559
+ body: unknown,
560
+ provider: OcxProviderConfig,
561
+ modelId: string,
562
+ ): unknown {
563
+ if (provider.authMode === "forward") return body;
564
+ if (configuredReasoningEfforts(provider, modelId) === undefined) return body;
565
+ if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body;
566
+ const requested = body.reasoning.effort;
567
+ if (typeof requested !== "string") return body;
568
+
569
+ const mapped = mapReasoningEffort(provider, modelId, requested);
570
+ if (!mapped || mapped === requested) return body;
571
+ return { ...body, reasoning: { ...body.reasoning, effort: mapped } };
572
+ }
573
+
552
574
  function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined {
553
575
  if (!isPlainObject(tool) || tool.type !== "function") return tool;
554
576
  if (xaiTarget) {
@@ -821,6 +843,40 @@ function toolOutputText(output: unknown): string {
821
843
  }).filter(Boolean).join("\n");
822
844
  }
823
845
 
846
+ /** True when a Responses tool output item is present but carries no usable content. */
847
+ function isToolOutputEmpty(output: unknown): boolean {
848
+ if (typeof output === "string") return output.trim() === "";
849
+ if (Array.isArray(output)) {
850
+ // Mirror the Chat wire rule through the shared contract: only a pure
851
+ // text/refusal part array whose joined content trims empty is annotated.
852
+ // input_image, encrypted_content, input_file and any other non-text part is
853
+ // real output and must never be replaced.
854
+ return isWhitespaceOnlyTextPartArray(output);
855
+ }
856
+ // A missing or null `output` is not a present-but-empty result: it is an
857
+ // incomplete payload. Leave it untouched so the upstream contract fails
858
+ // closed, and the orphan repair can surface it honestly instead of claiming
859
+ // the tool ran with no output.
860
+ return false;
861
+ }
862
+
863
+ /**
864
+ * Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic
865
+ * missing-result placeholders are non-empty and pass through untouched. No-op unless
866
+ * the provider opts in (`annotateEmptyToolOutputs`).
867
+ */
868
+ function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown {
869
+ if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body;
870
+ let changed = false;
871
+ const input = body.input.map(item => {
872
+ if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item;
873
+ if (!isToolOutputEmpty(item.output)) return item;
874
+ changed = true;
875
+ return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION };
876
+ });
877
+ return changed ? { ...body, input } : body;
878
+ }
879
+
824
880
  /**
825
881
  * Repair a forward-mode input array whose continuation context was lost. When the replay
826
882
  * expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
@@ -1202,6 +1258,13 @@ function canonicalForwardSystemText(item: Record<string, unknown>): string | nul
1202
1258
  return text;
1203
1259
  }
1204
1260
 
1261
+ /** Only message items may carry privileged system instructions. */
1262
+ function isCanonicalForwardSystemMessage(item: unknown): item is Record<string, unknown> {
1263
+ return isPlainObject(item)
1264
+ && (item.type === undefined || item.type === "message")
1265
+ && item.role === "system";
1266
+ }
1267
+
1205
1268
  /**
1206
1269
  * The public Responses API accepts input system messages and `truncation`, but the canonical
1207
1270
  * ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the
@@ -1225,7 +1288,7 @@ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown {
1225
1288
  let sawSystemMessage = false;
1226
1289
  let canFoldAllSystemMessages = true;
1227
1290
  for (const item of input) {
1228
- if (!isPlainObject(item) || item.role !== "system") continue;
1291
+ if (!isCanonicalForwardSystemMessage(item)) continue;
1229
1292
  sawSystemMessage = true;
1230
1293
  const text = canonicalForwardSystemText(item);
1231
1294
  if (text === null) {
@@ -1239,7 +1302,7 @@ function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown {
1239
1302
  const next: Record<string, unknown> = { ...body };
1240
1303
  if (stripTruncation) delete next.truncation;
1241
1304
  if (sawSystemMessage && canFoldAllSystemMessages) {
1242
- next.input = input.filter(item => !isPlainObject(item) || item.role !== "system");
1305
+ next.input = input.filter(item => !isCanonicalForwardSystemMessage(item));
1243
1306
  const folded = foldedText.join("\n\n");
1244
1307
  if (folded !== "") {
1245
1308
  const existing = typeof body.instructions === "string" ? body.instructions : "";
@@ -1991,6 +2054,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
1991
2054
  parsed._rawBody,
1992
2055
  forward || parsed._previousResponseInputExpanded === true,
1993
2056
  );
2057
+ outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
1994
2058
  // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
1995
2059
  // tier write so a force-fast/default decision can never mutate parsed._rawBody.
1996
2060
  outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
@@ -2001,6 +2065,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2001
2065
  // pair from its own storage either, so it needs the same repair the forward
2002
2066
  // backend gets — dropping previous_response_id is not much use if the body that
2003
2067
  // reaches the wire is unparseable.
2068
+ if (provider.annotateEmptyToolOutputs === true) {
2069
+ outBody = annotateEmptyResponsesToolOutputs(outBody, true);
2070
+ }
2004
2071
  if (forward || stateless) {
2005
2072
  outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
2006
2073
  }
@@ -2063,6 +2130,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
2063
2130
  // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
2064
2131
  // generic capability fallback removes the private OpenAI fields.
2065
2132
  outBody = normalizeXaiResponsesWebSearch(outBody, provider);
2133
+ outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen);
2066
2134
  // xAI and explicitly classified compatible gateways reject these OpenAI web_search
2067
2135
  // extensions. Keep them for OpenAI API-key traffic and unclassified gateways.
2068
2136
  if (provider.supportsOpenAiWebSearchToolFields === false) {
@@ -8,6 +8,7 @@ import { createGoogleAdapter } from "./google";
8
8
  import { createKiroAdapter } from "./kiro";
9
9
  import { createMimoFreeAdapter } from "./mimo-free";
10
10
  import { createOpenAIChatAdapter } from "./openai-chat";
11
+ import { createOllamaNativeAdapter } from "./ollama-native";
11
12
  import { createResponsesPassthroughAdapter } from "./openai-responses";
12
13
  import type { OcxProviderConfig } from "../types";
13
14
  import { createAdapterTierMetadata } from "../providers/fastwire";
@@ -21,6 +22,7 @@ export interface AdapterFactoryContext {
21
22
  export type AdapterWire =
22
23
  | "command-code"
23
24
  | "openai-chat"
25
+ | "ollama-native"
24
26
  | "anthropic"
25
27
  | "openai-responses"
26
28
  | "google"
@@ -62,6 +64,11 @@ export const ADAPTER_REGISTRY = {
62
64
  create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) =>
63
65
  withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)),
64
66
  },
67
+ "ollama-native": {
68
+ wire: "ollama-native",
69
+ mutation: "codex-owned",
70
+ create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOllamaNativeAdapter(provider),
71
+ },
65
72
  anthropic: {
66
73
  wire: "anthropic",
67
74
  mutation: "codex-owned",
@@ -3,6 +3,7 @@ import { isXaiResponsesDestination } from "../providers/xai-transport";
3
3
 
4
4
  const CODEX_WEB_SEARCH_TOOL = "web_search";
5
5
  const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview";
6
+ const XAI_SEARCH_TOOL = "x_search";
6
7
 
7
8
  function isPlainObject(value: unknown): value is Record<string, unknown> {
8
9
  return !!value && typeof value === "object" && !Array.isArray(value);
@@ -135,6 +136,15 @@ function normalizeToolChoice(body: Record<string, unknown>): Record<string, unkn
135
136
  return body;
136
137
  }
137
138
 
139
+ function currentInputStart(inputLength: number, replayPrefixLength: number | undefined): number {
140
+ if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return 0;
141
+ return Math.min(inputLength, Math.max(0, Math.trunc(replayPrefixLength)));
142
+ }
143
+
144
+ function hasToolType(tools: unknown, type: string): boolean {
145
+ return Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && tool.type === type);
146
+ }
147
+
138
148
  /**
139
149
  * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other
140
150
  * providers or mutating the caller-owned request body.
@@ -184,3 +194,51 @@ export function normalizeXaiResponsesWebSearch(
184
194
 
185
195
  return normalizeToolChoice(next);
186
196
  }
197
+
198
+ function isLiveWebSearchTool(tool: unknown): boolean {
199
+ return isPlainObject(tool)
200
+ && tool.type === CODEX_WEB_SEARCH_TOOL
201
+ && (!Object.hasOwn(tool, "external_web_access") || tool.external_web_access === true);
202
+ }
203
+
204
+ /**
205
+ * Add xAI's hosted X search declaration without changing web-search normalization or selectors.
206
+ * Destination classification belongs only to this opt-in injection path; the public-API
207
+ * normalizer above intentionally retains its narrower causality boundary.
208
+ */
209
+ export function injectXaiResponsesXSearch(
210
+ body: unknown,
211
+ provider: Pick<OcxProviderConfig, "baseUrl" | "xaiResponsesXSearch">,
212
+ replayPrefixLength?: number,
213
+ ): unknown {
214
+ if (
215
+ !isPlainObject(body)
216
+ || !isXaiResponsesDestination(provider)
217
+ || provider.xaiResponsesXSearch !== true
218
+ ) return body;
219
+
220
+ const input = Array.isArray(body.input) ? body.input : undefined;
221
+ const inputStart = input ? currentInputStart(input.length, replayPrefixLength) : 0;
222
+ const currentInput = input?.slice(inputStart) ?? [];
223
+ const currentXSearchDeclared = hasToolType(body.tools, XAI_SEARCH_TOOL)
224
+ || currentInput.some(item =>
225
+ isPlainObject(item)
226
+ && item.type === "additional_tools"
227
+ && hasToolType(item.tools, XAI_SEARCH_TOOL)
228
+ );
229
+ if (currentXSearchDeclared) return body;
230
+
231
+ const liveWebSearchSurvives = Array.isArray(body.tools) && body.tools.some(isLiveWebSearchTool)
232
+ || currentInput.some(item =>
233
+ isPlainObject(item)
234
+ && item.type === "additional_tools"
235
+ && Array.isArray(item.tools)
236
+ && item.tools.some(isLiveWebSearchTool)
237
+ );
238
+ if (!liveWebSearchSurvives) return body;
239
+
240
+ // Declaration does not grant selection when `tool_choice` names a specific tool or carries an
241
+ // `allowed_tools` set that excludes x_search, so leave that selector byte-shape untouched.
242
+ const tools = Array.isArray(body.tools) ? body.tools : [];
243
+ return { ...body, tools: [...tools, { type: XAI_SEARCH_TOOL }] };
244
+ }
@@ -585,7 +585,9 @@ export function writeDesktop3pConfig(
585
585
  ? metadata.entries.map(current => current === existing ? entry : current)
586
586
  : [...metadata.entries, entry];
587
587
 
588
- const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap), null, 2) + "\n";
588
+ const generated = generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile, nativeContextCap);
589
+ const preserved = readDesktopProfileForeignKeys(configPath);
590
+ const configJson = JSON.stringify({ ...preserved, ...generated }, null, 2) + "\n";
589
591
  const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
590
592
  const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson);
591
593
  try {
@@ -602,6 +604,24 @@ export function writeDesktop3pConfig(
602
604
  }
603
605
  }
604
606
 
607
+ const OPENCODEX_DESKTOP_PROFILE_KEYS = new Set([
608
+ "inferenceProvider",
609
+ "inferenceCredentialKind",
610
+ "inferenceGatewayBaseUrl",
611
+ "inferenceGatewayApiKey",
612
+ "modelDiscoveryEnabled",
613
+ "inferenceModels",
614
+ ]);
615
+
616
+ function readDesktopProfileForeignKeys(path: string): Record<string, unknown> {
617
+ if (!existsSync(path)) return {};
618
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
619
+ if (!isRecord(parsed)) throw new Error("Claude Desktop 3P profile is not a JSON object");
620
+ return Object.fromEntries(
621
+ Object.entries(parsed).filter(([key]) => !OPENCODEX_DESKTOP_PROFILE_KEYS.has(key)),
622
+ );
623
+ }
624
+
605
625
  /** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */
606
626
  export function atomicReplaceDesktopConfig(
607
627
  path: string,
@@ -0,0 +1,149 @@
1
+ /** Read-only, privacy-safe Windows policy diagnosis for Claude Desktop 3P. */
2
+ import { spawnSync } from "node:child_process";
3
+ import { win32 } from "node:path";
4
+ import { resolveTrustedWindowsSystemDirectory } from "../lib/windows-elevation";
5
+ import { decodeWindowsTextBytes } from "../lib/windows-text";
6
+
7
+ const CLAUDE_POLICY_KEY = "HKLM\\SOFTWARE\\Policies\\Claude";
8
+ const CLAUDE_POLICY_PARENT_KEY = "HKLM\\SOFTWARE\\Policies";
9
+ const POLICY_PROBE_TIMEOUT_MS = 2_000;
10
+
11
+ export type ClaudeDesktopPolicyState = "present" | "absent" | "unknown" | "not_applicable";
12
+
13
+ export interface ClaudeDesktopPolicyProbeResult {
14
+ readonly status: number | null;
15
+ /** Kept inside the probe boundary; diagnostics never return or log it. */
16
+ readonly stdout: string;
17
+ readonly timedOut: boolean;
18
+ readonly spawnFailed: boolean;
19
+ }
20
+
21
+ export type ClaudeDesktopPolicyProbeRunner = (
22
+ file: string,
23
+ args: readonly string[],
24
+ ) => ClaudeDesktopPolicyProbeResult;
25
+
26
+ export interface ClaudeDesktopPolicyProbeOptions {
27
+ readonly platform?: NodeJS.Platform;
28
+ readonly run?: ClaudeDesktopPolicyProbeRunner;
29
+ readonly resolveSystemDirectory?: () => string;
30
+ }
31
+
32
+ export interface ClaudeDesktopPolicyHealth {
33
+ readonly ok: boolean;
34
+ readonly status: "ok" | "warning";
35
+ readonly state: ClaudeDesktopPolicyState;
36
+ readonly message: string;
37
+ readonly action: string;
38
+ }
39
+
40
+ const defaultPolicyProbeRunner: ClaudeDesktopPolicyProbeRunner = (file, args) => {
41
+ const result = spawnSync(file, [...args], {
42
+ encoding: "buffer",
43
+ maxBuffer: 64 * 1024,
44
+ timeout: POLICY_PROBE_TIMEOUT_MS,
45
+ windowsHide: true,
46
+ });
47
+ const errorCode = (result.error as NodeJS.ErrnoException | undefined)?.code;
48
+ return {
49
+ status: result.status,
50
+ // Decoded only to prove key absence. This never crosses the probe boundary.
51
+ stdout: result.stdout ? decodeWindowsTextBytes(result.stdout) : "",
52
+ timedOut: errorCode === "ETIMEDOUT" || result.signal !== null,
53
+ spawnFailed: result.error !== undefined && errorCode !== "ETIMEDOUT",
54
+ };
55
+ };
56
+
57
+ function usable(result: ClaudeDesktopPolicyProbeResult): boolean {
58
+ return !result.timedOut && !result.spawnFailed && result.status !== null;
59
+ }
60
+
61
+ function parentListsPolicyKey(output: string): boolean {
62
+ const expected = CLAUDE_POLICY_KEY.toLowerCase();
63
+ return output.split(/\r?\n/).some(line => line.trim().toLowerCase()
64
+ .replace(/^hkey_local_machine\\/, "hklm\\") === expected);
65
+ }
66
+
67
+ /**
68
+ * Detect machine-level Claude managed policy without reading or exposing its contents.
69
+ *
70
+ * `reg.exe` returns exit 1 for both a missing key and query/access failures. As in the
71
+ * Windows tray registry reader, absence is therefore accepted only when the immediate
72
+ * parent can be queried successfully. Every other failure stays unknown.
73
+ */
74
+ export function probeClaudeDesktopPolicy(
75
+ options: ClaudeDesktopPolicyProbeOptions = {},
76
+ ): ClaudeDesktopPolicyState {
77
+ const platform = options.platform ?? process.platform;
78
+ if (platform !== "win32") return "not_applicable";
79
+
80
+ let regExe: string;
81
+ try {
82
+ const systemDirectory = (options.resolveSystemDirectory ?? resolveTrustedWindowsSystemDirectory)();
83
+ regExe = win32.join(systemDirectory, "reg.exe");
84
+ } catch {
85
+ return "unknown";
86
+ }
87
+
88
+ const run = options.run ?? defaultPolicyProbeRunner;
89
+ let policy: ClaudeDesktopPolicyProbeResult;
90
+ try {
91
+ policy = run(regExe, ["query", CLAUDE_POLICY_KEY, "/reg:64"]);
92
+ } catch {
93
+ return "unknown";
94
+ }
95
+ if (!usable(policy)) return "unknown";
96
+ if (policy.status === 0) return "present";
97
+ if (policy.status !== 1) return "unknown";
98
+
99
+ let parent: ClaudeDesktopPolicyProbeResult;
100
+ try {
101
+ parent = run(regExe, ["query", CLAUDE_POLICY_PARENT_KEY, "/reg:64"]);
102
+ } catch {
103
+ return "unknown";
104
+ }
105
+ if (!usable(parent) || parent.status !== 0) return "unknown";
106
+ // The child can be listed by a readable parent while its own ACL blocks the
107
+ // query. That is unreadable, not absent.
108
+ return parentListsPolicyKey(parent.stdout) ? "unknown" : "absent";
109
+ }
110
+
111
+ /** State-only health projection shared by CLI, apply, and management status. */
112
+ export function claudeDesktopPolicyHealth(
113
+ state: ClaudeDesktopPolicyState,
114
+ ): ClaudeDesktopPolicyHealth {
115
+ if (state === "present") {
116
+ return {
117
+ ok: false,
118
+ status: "warning",
119
+ state,
120
+ message: "Windows managed Claude policy is active, so Claude Desktop will ignore the local third-party profile.",
121
+ action: "Ask your administrator to remove or revise the managed Claude policy, then fully quit and reopen Claude Desktop.",
122
+ };
123
+ }
124
+ if (state === "unknown") {
125
+ return {
126
+ ok: false,
127
+ status: "warning",
128
+ state,
129
+ message: "OpenCodex could not verify Windows managed Claude policy; Desktop third-party profile health is unverified.",
130
+ action: "Check access to Windows machine policy and run the status check again before relying on Claude Desktop 3P.",
131
+ };
132
+ }
133
+ return {
134
+ ok: true,
135
+ status: "ok",
136
+ state,
137
+ message: state === "absent"
138
+ ? "No Windows managed Claude policy was detected."
139
+ : "Windows managed Claude policy is not applicable on this platform.",
140
+ action: "No action required.",
141
+ };
142
+ }
143
+
144
+ export function claudeDesktopPolicyWarning(
145
+ state: ClaudeDesktopPolicyState,
146
+ ): string | undefined {
147
+ const health = claudeDesktopPolicyHealth(state);
148
+ return health.ok ? undefined : `${health.message} Action: ${health.action}`;
149
+ }
@@ -25,8 +25,19 @@ type TargetProvenance = "live-oauth-list" | "config" | "codex";
25
25
 
26
26
  const MAIN_ALIAS = "main";
27
27
  const MAIN_CODEX_ID = "__main__";
28
- /** Replacement-style single-slot OAuth (no stable identity; not HTTP-derivable). */
29
- const REPLACEMENT_STYLE_OAUTH = new Set(["kiro"]);
28
+ /**
29
+ * Replacement-style single-slot OAuth (no stable identity; not HTTP-derivable).
30
+ *
31
+ * Empty since `d82b3049d` gave Kiro a quota-aware account pool: multiple Kiro accounts are
32
+ * stored under multiauth, ranked by remaining headroom in `rankAccountsByHeadroom`, and
33
+ * rotated on 429 by the generic OAuth failover path, which does not exclude Kiro. Printing a
34
+ * "single login slot" note alongside a list of several pooled accounts told operators the
35
+ * opposite of what the runtime does.
36
+ *
37
+ * Kept as a named seam rather than deleted: the replacement-style shape is a real category,
38
+ * and a future provider without stable per-account identity belongs here.
39
+ */
40
+ const REPLACEMENT_STYLE_OAUTH = new Set<string>();
30
41
 
31
42
  const ACCOUNT_USAGE = `Usage:
32
43
  ocx account list [provider] [--json] [--all] [--quota [--refresh]]
@@ -111,6 +122,9 @@ function quotaText(row: AccountRow): string {
111
122
  const short = quota.fiveHourPercent ?? quota.shortPercent;
112
123
  if (typeof short === "number") parts.push(`5h ${short}%`);
113
124
  if (typeof quota.weeklyPercent === "number") parts.push(`wk ${quota.weeklyPercent}%`);
125
+ // Kiro bills a monthly allowance and reports no shorter window, so without this arm a
126
+ // perfectly healthy Kiro account prints "-" and reads as broken.
127
+ if (typeof quota.monthlyPercent === "number") parts.push(`mo ${Math.round(quota.monthlyPercent)}%`);
114
128
  return parts.length > 0 ? parts.join(" ") : "-";
115
129
  }
116
130
 
@@ -37,7 +37,8 @@ export interface ApplyProfileDeps {
37
37
  postApplyImpl?: (
38
38
  mode: Desktop3pConfigMode,
39
39
  profile: DesktopProfile,
40
- ) => Promise<{ ok?: boolean; path?: string; error?: string }>;
40
+ ) => Promise<{ ok?: boolean; path?: string; error?: string; warning?: string }>;
41
+ probeClaudeDesktopPolicy?: typeof import("../claude/desktop-policy").probeClaudeDesktopPolicy;
41
42
  }
42
43
 
43
44
  export async function applyProfile(
@@ -71,10 +72,11 @@ export async function applyProfile(
71
72
  // Partial success: Desktop was written but the applied marker was not
72
73
  // persisted. Pass the degradation up instead of reporting a clean apply.
73
74
  const partial = (applied as { saved?: boolean; warning?: string }).saved === false;
75
+ const warning = (applied as { warning?: string }).warning;
74
76
  return {
75
77
  ok: true,
76
78
  path: applied.path ?? "",
77
- ...(partial ? { warning: (applied as { warning?: string }).warning ?? "applied marker was not saved" } : {}),
79
+ ...(warning ? { warning } : partial ? { warning: "applied marker was not saved" } : {}),
78
80
  };
79
81
  } catch (error) {
80
82
  return { ok: false, path: "", reason: error instanceof Error ? error.message : String(error) };
@@ -102,7 +104,15 @@ export async function applyProfile(
102
104
  state.profile,
103
105
  nativeContextLimits(config),
104
106
  );
105
- return { ok: result.written, path: result.path, reason: result.reason };
107
+ const { claudeDesktopPolicyWarning, probeClaudeDesktopPolicy } = await import("../claude/desktop-policy");
108
+ const policyState = (deps.probeClaudeDesktopPolicy ?? probeClaudeDesktopPolicy)();
109
+ const warning = result.written ? claudeDesktopPolicyWarning(policyState) : undefined;
110
+ return {
111
+ ok: result.written,
112
+ path: result.path,
113
+ reason: result.reason,
114
+ ...(warning ? { warning } : {}),
115
+ };
106
116
  }
107
117
 
108
118
  export async function handleClaudeDesktopCommand(argv: string[], deps: ApplyProfileDeps = {}): Promise<number> {
package/src/cli/combo.ts CHANGED
@@ -14,7 +14,7 @@ const USAGE = `Usage:
14
14
  ocx combo [list] [--json]
15
15
  ocx combo show <id> [--json]
16
16
  ocx combo set <id> --targets <provider/model[:weight],...>
17
- [--strategy <failover|round-robin>] [--sticky <1-100>]
17
+ [--strategy <failover|round-robin|random|least-used|reset-window>] [--sticky <1-100>]
18
18
  [--effort <low|medium|high|xhigh|max|ultra|->] [--alias <name|->]
19
19
  [--native-alias] [--display-name <label|->]
20
20
  [--rename-from <id>] [--json]
@@ -73,9 +73,12 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
73
73
  const targetsRaw = takeOption(args, "--targets");
74
74
  if (!targetsRaw) throw new CliUsageError("--targets is required", USAGE);
75
75
  const strategy = takeOption(args, "--strategy") ?? "failover";
76
- if (strategy !== "failover" && strategy !== "round-robin") throw new CliUsageError("--strategy must be failover or round-robin", USAGE);
77
- const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 }) ?? 1;
78
- if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
76
+ if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, or reset-window", USAGE);
77
+ const stickyLimit = takeIntegerOption(args, "--sticky", { min: 1 });
78
+ if (stickyLimit !== undefined) {
79
+ if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
80
+ if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE);
81
+ }
79
82
  const effort = takeOption(args, "--effort");
80
83
  const alias = takeOption(args, "--alias");
81
84
  const nativeAlias = takeFlag(args, "--native-alias");
@@ -84,7 +87,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<void> {
84
87
  rejectArgs(args, USAGE);
85
88
  const combo: Record<string, unknown> = {
86
89
  strategy,
87
- stickyLimit,
90
+ stickyLimit: stickyLimit ?? 1,
88
91
  targets: parseTargets(targetsRaw),
89
92
  };
90
93
  if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;