@bitkyc08/opencodex 2.18.2 → 2.20.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 (131) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/google-antigravity-wire.ts +4 -3
  7. package/src/adapters/google.ts +1 -1
  8. package/src/adapters/mimo-free.ts +17 -0
  9. package/src/adapters/openai-chat.ts +367 -32
  10. package/src/adapters/registry.ts +144 -0
  11. package/src/chat/inbound.ts +37 -8
  12. package/src/cli/claude.ts +2 -1
  13. package/src/cli/combo.ts +3 -0
  14. package/src/cli/dispatch.ts +8 -0
  15. package/src/cli/export-command.ts +2 -2
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +3 -2
  18. package/src/cli/lab.ts +135 -1
  19. package/src/cli/minimax.ts +491 -0
  20. package/src/cli/models-runtime.ts +22 -1
  21. package/src/cli/models.ts +67 -2
  22. package/src/cli/opencode.ts +2 -1
  23. package/src/cli/registry.ts +22 -2
  24. package/src/clients/config-export.ts +125 -7
  25. package/src/codex/app-server-processes.ts +57 -2
  26. package/src/codex/app-server-restart-service.ts +232 -0
  27. package/src/codex/catalog/aggregation.ts +10 -1
  28. package/src/codex/catalog/effort.ts +15 -3
  29. package/src/codex/catalog/parsing.ts +3 -1
  30. package/src/codex/catalog/provider-fetch.ts +45 -5
  31. package/src/codex/catalog/sync.ts +74 -4
  32. package/src/codex/convergence.ts +2 -0
  33. package/src/codex/shim.ts +100 -5
  34. package/src/combos/index.ts +1 -0
  35. package/src/combos/request.ts +30 -0
  36. package/src/combos/types.ts +6 -0
  37. package/src/config.ts +52 -0
  38. package/src/generated/compatibility-version.json +239 -87
  39. package/src/images/loop.ts +11 -1
  40. package/src/integrations/registry.ts +7 -0
  41. package/src/lab/conformance/jcs.ts +42 -2
  42. package/src/lab/conformance/negative-controls.ts +6 -2
  43. package/src/lab/conformance/runner.ts +16 -5
  44. package/src/lab/fabric/observe.ts +49 -14
  45. package/src/lab/index.ts +16 -0
  46. package/src/lab/ledger/purge.ts +152 -83
  47. package/src/lab/ledger/store.ts +168 -54
  48. package/src/lab/observe/from-conformance.ts +8 -6
  49. package/src/lab/observe/from-live.ts +8 -2
  50. package/src/lab/paths.ts +23 -0
  51. package/src/lab/public/bundle.ts +217 -0
  52. package/src/lab/public/community-authority.ts +175 -0
  53. package/src/lab/public/community-files.ts +29 -0
  54. package/src/lab/public/community.ts +479 -0
  55. package/src/lab/public/file-safety.ts +155 -0
  56. package/src/lab/public/ids.ts +26 -0
  57. package/src/lab/public/index.ts +16 -0
  58. package/src/lab/public/mutation-lock.ts +424 -0
  59. package/src/lab/public/operator.ts +353 -0
  60. package/src/lab/public/origin-purge.ts +79 -0
  61. package/src/lab/public/origin.ts +203 -0
  62. package/src/lab/public/privacy.ts +143 -0
  63. package/src/lab/public/private-file.ts +261 -0
  64. package/src/lab/public/project.ts +124 -0
  65. package/src/lab/public/purge-test-fault.ts +21 -0
  66. package/src/lab/public/purge.ts +223 -0
  67. package/src/lab/public/registry.ts +44 -0
  68. package/src/lab/public/revocation.ts +252 -0
  69. package/src/lab/public/signature.ts +219 -0
  70. package/src/lab/public/storage.ts +105 -0
  71. package/src/lab/public/strict-json.ts +206 -0
  72. package/src/lab/public/time.ts +26 -0
  73. package/src/lab/public/types.ts +172 -0
  74. package/src/lab/public/validate.ts +391 -0
  75. package/src/lib/codex-restart-contract.ts +120 -0
  76. package/src/lib/errors.ts +27 -0
  77. package/src/lib/lab-activation.ts +109 -47
  78. package/src/lib/lab-live-pinned-sender.ts +16 -5
  79. package/src/lib/pinned-http.ts +70 -16
  80. package/src/lib/self-launch-argv.ts +15 -0
  81. package/src/lib/state-store-registrations.ts +2 -0
  82. package/src/lib/upstream-reachability.ts +4 -0
  83. package/src/lib/windows-elevation.ts +10 -1
  84. package/src/providers/derive.ts +24 -4
  85. package/src/providers/registry.ts +9 -3
  86. package/src/providers/request-pacing.ts +310 -0
  87. package/src/providers/service-tier.ts +143 -0
  88. package/src/providers/static-model-discovery.ts +86 -0
  89. package/src/reasoning-effort.ts +27 -1
  90. package/src/responses/spill-store.ts +20 -1
  91. package/src/responses/state.ts +159 -3
  92. package/src/router.ts +23 -6
  93. package/src/routing/capability.ts +4 -2
  94. package/src/routing/compatibility/behavior.ts +5 -1
  95. package/src/server/adapter-resolve.ts +2 -32
  96. package/src/server/auth-cors.ts +8 -0
  97. package/src/server/chat-completions.ts +74 -34
  98. package/src/server/chat-native-sse.ts +331 -0
  99. package/src/server/chat-native.ts +371 -0
  100. package/src/server/effort-policy.ts +18 -0
  101. package/src/server/management/combo-routes.ts +16 -2
  102. package/src/server/management/config-routes.ts +6 -4
  103. package/src/server/management/context.ts +17 -0
  104. package/src/server/management/lab-routes.ts +181 -19
  105. package/src/server/management/model-routes.ts +76 -2
  106. package/src/server/management/model-rows.ts +8 -0
  107. package/src/server/management/provider-capability-config.ts +48 -0
  108. package/src/server/management/provider-routes.ts +76 -4
  109. package/src/server/management/system-restart.ts +4 -2
  110. package/src/server/management/system-routes.ts +38 -0
  111. package/src/server/relay.ts +17 -3
  112. package/src/server/responses/compact.ts +4 -1
  113. package/src/server/responses/core.ts +278 -48
  114. package/src/server/responses/empty-completion-guard.ts +276 -0
  115. package/src/server/responses/fetch-helpers.ts +35 -4
  116. package/src/server/responses/input-admission.ts +169 -0
  117. package/src/server/responses/pacing-overload.ts +13 -0
  118. package/src/server/responses/policy-fallback.ts +16 -2
  119. package/src/server/responses/terminal-guard.ts +1 -1
  120. package/src/server/responses/upstream-error.ts +5 -0
  121. package/src/server/responses.ts +17 -2
  122. package/src/service-manager-probe.ts +2 -3
  123. package/src/types.ts +76 -6
  124. package/src/update/index.ts +6 -5
  125. package/src/update/job.ts +5 -6
  126. package/src/update/notify.ts +5 -3
  127. package/src/usage/log.ts +11 -1
  128. package/src/vision/index.ts +21 -4
  129. package/src/web-search/index.ts +2 -1
  130. package/gui/dist/assets/index-CXI1262_.js +0 -76
  131. package/gui/dist/assets/index-DUCH59lJ.css +0 -1
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-CXI1262_.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-DUCH59lJ.css">
19
+ <script type="module" crossorigin src="/assets/index-DSK3S5HY.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-DF_UFrGS.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.18.2",
3
+ "version": "2.20.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -40,20 +40,24 @@ export function claudeCodeSessionId(token: string | undefined): string {
40
40
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${variant}${h.slice(17, 20)}-${h.slice(20, 32)}`;
41
41
  }
42
42
 
43
- // ── Antigravity CLI ──
44
- /** Pinned fallback Antigravity CLI version (real client fetches a manifest; we pin to avoid the network dependency). */
45
- export const ANTIGRAVITY_CLI_VERSION = "1.1.12";
46
- const ANTIGRAVITY_CLI_CLIENT_NAME = "aidev_client";
47
- const ANTIGRAVITY_CLI_PLATFORM = "darwin/arm64";
43
+ // ── Antigravity IDE ──
44
+ /** Pinned fallback Antigravity IDE language-server version (matches the bundled LS 2.5.5). */
45
+ export const ANTIGRAVITY_IDE_VERSION = "2.5.5";
46
+ const ANTIGRAVITY_IDE_CLIENT_NAME = "aidev_client";
47
+ const ANTIGRAVITY_IDE_PLATFORM = "windows/amd64";
48
48
  /** Secondary Google API client UA the Antigravity client library reports. */
49
49
  export const ANTIGRAVITY_GOOG_API_CLIENT_UA = "google-api-nodejs-client/10.3.0";
50
50
 
51
51
  /**
52
- * The real Antigravity CLI User-Agent, e.g.
53
- * `antigravity/cli/1.1.12 (aidev_client; os_type=darwin; arch=arm64)`.
52
+ * The real Antigravity IDE User-Agent, e.g.
53
+ * `antigravity/ide/2.5.5 (aidev_client; os_type=windows; arch=amd64)`.
54
+ *
55
+ * Must be the IDE client family, NOT `antigravity/cli/...`: the Cloud Code Assist backend gates
56
+ * newer agent models (e.g. `gemini-3.7-flash`) by User-Agent and answers 404 NOT_FOUND to
57
+ * CLI-shaped UAs even with a valid OAuth token. Only `antigravity/ide/<ver>` unlocks them.
54
58
  * A `GOOGLE_ANTIGRAVITY_USER_AGENT` override (set by the caller) takes precedence upstream.
55
59
  */
56
- export function antigravityUserAgent(version = ANTIGRAVITY_CLI_VERSION): string {
57
- const [osType, arch] = ANTIGRAVITY_CLI_PLATFORM.split("/");
58
- return `antigravity/cli/${version} (${ANTIGRAVITY_CLI_CLIENT_NAME}; os_type=${osType}; arch=${arch})`;
60
+ export function antigravityUserAgent(version = ANTIGRAVITY_IDE_VERSION): string {
61
+ const [osType, arch] = ANTIGRAVITY_IDE_PLATFORM.split("/");
62
+ return `antigravity/ide/${version} (${ANTIGRAVITY_IDE_CLIENT_NAME}; os_type=${osType}; arch=${arch})`;
59
63
  }
@@ -3,10 +3,11 @@ import type { OcxContentPart, OcxParsedRequest } from "../types";
3
3
  import { antigravityUserAgent } from "./client-fingerprint";
4
4
 
5
5
  /**
6
- * Antigravity request User-Agent. Mirrors the real Antigravity CLI UA
7
- * (`antigravity/cli/{ver} (aidev_client; os_type=darwin; arch=arm64)`) so the request fingerprint
6
+ * Antigravity request User-Agent. Mirrors the real Antigravity IDE UA
7
+ * (`antigravity/ide/{ver} (aidev_client; os_type=windows; arch=amd64)`) so the request fingerprint
8
8
  * matches the OAuth credential — the prior literal `"antigravity"` was a giveaway no real client
9
- * sends. A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
9
+ * sends. The IDE client family is also required to unlock newer agent models (the backend 404s
10
+ * CLI-shaped UAs for `gemini-3.7-*`). A `GOOGLE_ANTIGRAVITY_USER_AGENT` override still wins.
10
11
  */
11
12
  export const ANTIGRAVITY_REQUEST_UA = process.env.GOOGLE_ANTIGRAVITY_USER_AGENT || antigravityUserAgent();
12
13
 
@@ -469,7 +469,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
469
469
  const envelope = {
470
470
  model: wireModelId,
471
471
  // The envelope's `userAgent` field is a protocol constant ("antigravity"), distinct from
472
- // the HTTP `User-Agent` header (the real CLI UA). CLIProxyAPI `geminiToAntigravity` hardcodes
472
+ // the HTTP `User-Agent` header (the real IDE UA). CLIProxyAPI `geminiToAntigravity` hardcodes
473
473
  // the body field; only the header carries the versioned client string.
474
474
  userAgent: "antigravity",
475
475
  requestType: "agent",
@@ -40,6 +40,18 @@ function randomUserAgent(): string {
40
40
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]!;
41
41
  }
42
42
 
43
+ function isCanonicalMimoFreeEndpoint(baseUrl: string): boolean {
44
+ try {
45
+ const actual = new URL(baseUrl.trim());
46
+ const expected = new URL(MIMO_CHAT_URL);
47
+ actual.pathname = actual.pathname.replace(/\/+$/, "") || "/";
48
+ expected.pathname = expected.pathname.replace(/\/+$/, "") || "/";
49
+ return actual.toString().replace(/\/$/, "") === expected.toString().replace(/\/$/, "");
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
43
55
  /**
44
56
  * Anonymous per-install client id for the bootstrap `client` field. A random UUID
45
57
  * persisted under the config dir (OPENCODEX_HOME-aware) — deliberately NOT derived
@@ -194,6 +206,11 @@ export function injectMimoSystemMarker(body: unknown): unknown {
194
206
  * On 401/403, flushes the JWT cache and retries once via fetchResponse.
195
207
  */
196
208
  export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdapter {
209
+ if (!isCanonicalMimoFreeEndpoint(provider.baseUrl)) {
210
+ throw new Error(
211
+ "The mimo-free adapter only supports the canonical Xiaomi MiMo Free endpoint. Use openai-chat for a custom endpoint.",
212
+ );
213
+ }
197
214
  const base = createOpenAIChatAdapter(provider);
198
215
  // Per-adapter session-affinity id (random, per process instance).
199
216
  const sessionId = `ses_${Math.random().toString(36).slice(2, 26)}`;
@@ -12,6 +12,7 @@ import { identifyRoutedModel } from "./identity";
12
12
  import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
13
13
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
14
14
  import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
15
+ import { canSerializeServiceTierForChatModel } from "../providers/service-tier";
15
16
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
16
17
  import {
17
18
  isTranslatorBudgetExceededError,
@@ -34,6 +35,122 @@ export function stripBracketedModelSuffix(modelId: string): string {
34
35
  return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart);
35
36
  }
36
37
 
38
+ const CHAT_PASSTHROUGH_FIELDS = [
39
+ "audio",
40
+ "frequency_penalty",
41
+ "logit_bias",
42
+ "logprobs",
43
+ "max_completion_tokens",
44
+ "max_tokens",
45
+ "metadata",
46
+ "modalities",
47
+ "n",
48
+ "prediction",
49
+ "presence_penalty",
50
+ "reasoning_effort",
51
+ "response_format",
52
+ "seed",
53
+ "stop",
54
+ "store",
55
+ "temperature",
56
+ "tool_choice",
57
+ "tools",
58
+ "top_logprobs",
59
+ "top_p",
60
+ "user",
61
+ "web_search_options",
62
+ ] as const;
63
+
64
+ function openAIChatTransport(provider: OcxProviderConfig): {
65
+ url: string;
66
+ headers: Record<string, string>;
67
+ hasCredential: boolean;
68
+ } {
69
+ const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
70
+ if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
71
+ throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
72
+ }
73
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
74
+ if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
75
+ if (provider.headers) Object.assign(headers, provider.headers);
76
+ return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
77
+ }
78
+
79
+ /**
80
+ * Build a provider request from an inbound Chat Completions body without translating it
81
+ * through the Responses contract. This is deliberately a whitelist: Chat-only caller
82
+ * fields retain their exact wire representation, while provider capability gates remain
83
+ * centralized beside the ordinary openai-chat adapter.
84
+ */
85
+ export function buildOpenAIChatPassthroughRequest(
86
+ provider: OcxProviderConfig,
87
+ rawBody: Record<string, unknown>,
88
+ modelId: string,
89
+ stream: boolean,
90
+ ): AdapterRequest {
91
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
92
+
93
+ const body: Record<string, unknown> = {
94
+ model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
95
+ messages: rawBody.messages,
96
+ stream,
97
+ };
98
+ for (const field of CHAT_PASSTHROUGH_FIELDS) {
99
+ if (rawBody[field] !== undefined) body[field] = rawBody[field];
100
+ }
101
+
102
+ if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
103
+ if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
104
+ if (modelInList(provider.noPenaltyModels, modelId)) {
105
+ delete body.presence_penalty;
106
+ delete body.frequency_penalty;
107
+ }
108
+ if (modelInList(provider.noStructuredOutputModels, modelId)) delete body.response_format;
109
+
110
+ if (provider.chatServiceTier && rawBody.service_tier !== undefined) {
111
+ body.service_tier = rawBody.service_tier;
112
+ }
113
+ if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
114
+ body.prompt_cache_key = rawBody.prompt_cache_key;
115
+ }
116
+ if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
117
+ if (provider.parallelToolCalls === true) {
118
+ body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
119
+ } else if (provider.parallelToolCalls === false
120
+ && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
121
+ body.parallel_tool_calls = false;
122
+ }
123
+ }
124
+ if (stream) {
125
+ const callerOptions = rawBody.stream_options !== null
126
+ && typeof rawBody.stream_options === "object"
127
+ && !Array.isArray(rawBody.stream_options)
128
+ ? rawBody.stream_options as Record<string, unknown>
129
+ : {};
130
+ body.stream_options = { ...callerOptions, include_usage: true };
131
+ } else if (rawBody.stream_options !== undefined) {
132
+ body.stream_options = rawBody.stream_options;
133
+ }
134
+
135
+ const bodyJson = JSON.stringify(body);
136
+
137
+ if (isDebugEnabled()) {
138
+ let host = "upstream";
139
+ try { host = new URL(url).host; } catch { /* keep fallback */ }
140
+ debugProviderDiagnostic("openai-chat", "passthrough-request", {
141
+ host,
142
+ model: body.model,
143
+ stream,
144
+ messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
145
+ toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
146
+ hasCredential,
147
+ bodyBytes: new TextEncoder().encode(bodyJson).length,
148
+ });
149
+ }
150
+
151
+ return { url, method: "POST", headers, body: bodyJson };
152
+ }
153
+
37
154
  // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
38
155
  // loop.ts only appends a suffix to "Provider error N" when the adapter exposes
39
156
  // formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
@@ -658,11 +775,11 @@ function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
658
775
  || baseUrl === "https://opencode.ai/zen/go/v1";
659
776
  }
660
777
 
661
- const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
662
-
663
778
  function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
664
779
  try {
665
- return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
780
+ // Public api.x.ai accepts native root object unions. Only the Grok CLI proxy
781
+ // 400s on a root oneOf/anyOf, so flattening/omitting is scoped to that host.
782
+ return new URL(provider.baseUrl).hostname === "cli-chat-proxy.grok.com";
666
783
  } catch {
667
784
  return false;
668
785
  }
@@ -696,36 +813,262 @@ function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
696
813
  return { ...obj, type: "object" };
697
814
  }
698
815
 
816
+ function isXaiObjectSchema(value: unknown): value is Record<string, unknown> {
817
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
818
+ }
819
+
820
+ function stringRequiredFields(value: unknown): string[] {
821
+ return Array.isArray(value)
822
+ ? value.filter((item): item is string => typeof item === "string")
823
+ : [];
824
+ }
825
+
826
+ /** Variant keys the merger can keep. Anything else is refused, not silently dropped. */
827
+ const XAI_VARIANT_MERGE_KEYS = new Set([
828
+ "type",
829
+ "properties",
830
+ "required",
831
+ "additionalProperties",
832
+ "description",
833
+ "title",
834
+ "$comment",
835
+ "$defs",
836
+ "definitions",
837
+ ]);
838
+
839
+ function decodeJsonPointerToken(token: string): string {
840
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
841
+ }
842
+
843
+ function lookupLocalJsonPointer(root: unknown, ref: string): unknown {
844
+ if (ref === "#" || ref === "#/") return root;
845
+ if (!ref.startsWith("#/")) return undefined;
846
+ let current: unknown = root;
847
+ for (const token of ref.slice(2).split("/").map(decodeJsonPointerToken)) {
848
+ if (!isXaiObjectSchema(current) || !Object.hasOwn(current, token)) return undefined;
849
+ current = current[token];
850
+ }
851
+ return current;
852
+ }
853
+
854
+ /** Resolve local `#/` `$ref`s. Unresolvable or cyclic refs return undefined. */
855
+ function resolveXaiSchemaRefs(
856
+ schema: unknown,
857
+ root: Record<string, unknown>,
858
+ stack: Set<string> = new Set(),
859
+ ): unknown | undefined {
860
+ if (!isXaiObjectSchema(schema)) return schema;
861
+ if (typeof schema.$ref === "string") {
862
+ const ref = schema.$ref;
863
+ if (stack.has(ref)) return undefined;
864
+ const target = lookupLocalJsonPointer(root, ref);
865
+ if (target === undefined) return undefined;
866
+ stack.add(ref);
867
+ const resolvedTarget = resolveXaiSchemaRefs(target, root, stack);
868
+ stack.delete(ref);
869
+ if (resolvedTarget === undefined) return undefined;
870
+ const rest: Record<string, unknown> = { ...schema };
871
+ delete rest.$ref;
872
+ if (Object.keys(rest).length === 0) return resolvedTarget;
873
+ const resolvedRest = resolveXaiSchemaRefs(rest, root, stack);
874
+ if (resolvedRest === undefined || !isXaiObjectSchema(resolvedTarget) || !isXaiObjectSchema(resolvedRest)) {
875
+ return undefined;
876
+ }
877
+ return composeXaiObjectSchemas(resolvedTarget, resolvedRest);
878
+ }
879
+
880
+ const resolved: Record<string, unknown> = {};
881
+ for (const [key, value] of Object.entries(schema)) {
882
+ if ((key === "oneOf" || key === "anyOf") && Array.isArray(value)) {
883
+ const items: unknown[] = [];
884
+ for (const item of value) {
885
+ const next = resolveXaiSchemaRefs(item, root, stack);
886
+ if (next === undefined) return undefined;
887
+ items.push(next);
888
+ }
889
+ resolved[key] = items;
890
+ continue;
891
+ }
892
+ if (key === "properties" && isXaiObjectSchema(value)) {
893
+ const properties: Record<string, unknown> = {};
894
+ for (const [name, property] of Object.entries(value)) {
895
+ const next = resolveXaiSchemaRefs(property, root, stack);
896
+ if (next === undefined) return undefined;
897
+ properties[name] = next;
898
+ }
899
+ resolved[key] = properties;
900
+ continue;
901
+ }
902
+ resolved[key] = value;
903
+ }
904
+ return resolved;
905
+ }
906
+
907
+ function xaiVariantIsConcreteObject(variant: Record<string, unknown>): boolean {
908
+ if (variant.type !== undefined && variant.type !== "object") return false;
909
+ return Object.keys(variant).every(key => XAI_VARIANT_MERGE_KEYS.has(key));
910
+ }
911
+
912
+ function variantProperties(variant: Record<string, unknown>): Record<string, unknown> {
913
+ return isXaiObjectSchema(variant.properties) ? variant.properties : {};
914
+ }
915
+
916
+ /**
917
+ * Independent per-property anyOf is lossless only when every property name exists
918
+ * on every variant (absence is meaningful under xAI's default additionalProperties:
919
+ * false, and promoting a branch-local key also tightens explicit-true variants)
920
+ * and at most one of those shared properties has a conflicting schema.
921
+ */
922
+ function xaiPropertyMergeIsLossless(variants: Record<string, unknown>[]): boolean {
923
+ const names = new Set<string>();
924
+ const props = variants.map(variant => {
925
+ const properties = variantProperties(variant);
926
+ for (const name of Object.keys(properties)) names.add(name);
927
+ return properties;
928
+ });
929
+ let schemaConflicts = 0;
930
+ for (const name of names) {
931
+ const values = props.map(property => property[name]);
932
+ if (values.some(value => value === undefined)) return false;
933
+ if (values.some(value => JSON.stringify(value) !== JSON.stringify(values[0]))) schemaConflicts += 1;
934
+ }
935
+ return schemaConflicts <= 1;
936
+ }
937
+
938
+ function xaiRequiredSetsMatch(variants: Record<string, unknown>[]): boolean {
939
+ const serialized = variants.map(variant => [...stringRequiredFields(variant.required)].sort().join("\0"));
940
+ return serialized.every(value => value === serialized[0]);
941
+ }
942
+
943
+ function mergeXaiAdditionalProperties(
944
+ variants: Record<string, unknown>[],
945
+ ): { ok: true; value?: unknown } | { ok: false } {
946
+ const values = variants.map(variant => variant.additionalProperties);
947
+ const explicit = values.filter(value => value !== undefined);
948
+ if (explicit.length === 0) return { ok: true };
949
+ if (explicit.length !== values.length) return { ok: false };
950
+ const hasFalse = explicit.some(value => value === false);
951
+ const permissive = explicit.filter(value => value !== false);
952
+ if (hasFalse && permissive.length > 0) return { ok: false };
953
+ if (hasFalse) return { ok: true, value: false };
954
+ const unique: unknown[] = [];
955
+ const seen = new Set<string>();
956
+ for (const value of permissive) {
957
+ const key = JSON.stringify(value);
958
+ if (seen.has(key)) continue;
959
+ seen.add(key);
960
+ unique.push(value);
961
+ }
962
+ if (unique.length !== 1) return { ok: false };
963
+ return { ok: true, value: unique[0] };
964
+ }
965
+
966
+ /** Compose root siblings into a branch so properties/required are not overwritten. */
967
+ function composeXaiObjectSchemas(
968
+ inherited: Record<string, unknown>,
969
+ branch: Record<string, unknown>,
970
+ ): Record<string, unknown> {
971
+ const composed: Record<string, unknown> = { ...inherited, ...branch };
972
+ const inheritedProps = isXaiObjectSchema(inherited.properties) ? inherited.properties : undefined;
973
+ const branchProps = isXaiObjectSchema(branch.properties) ? branch.properties : undefined;
974
+ if (inheritedProps || branchProps) {
975
+ const properties: Record<string, unknown> = { ...(inheritedProps ?? {}) };
976
+ for (const [name, value] of Object.entries(branchProps ?? {})) {
977
+ const inheritedValue = inheritedProps?.[name];
978
+ properties[name] = inheritedValue !== undefined && JSON.stringify(inheritedValue) !== JSON.stringify(value)
979
+ ? { allOf: [inheritedValue, value] }
980
+ : value;
981
+ }
982
+ composed.properties = properties;
983
+ }
984
+ const required = [...new Set([
985
+ ...stringRequiredFields(inherited.required),
986
+ ...stringRequiredFields(branch.required),
987
+ ])];
988
+ if (required.length > 0) composed.required = required;
989
+ else delete composed.required;
990
+ return composed;
991
+ }
992
+
699
993
  function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
700
- if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
701
- const obj = schema as Record<string, unknown>;
702
- const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
994
+ if (!isXaiObjectSchema(schema)) return undefined;
995
+ const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(schema[key]));
703
996
  if (!compositionKey) {
704
- if (obj.type !== undefined && obj.type !== "object") return undefined;
705
- return [{ ...obj, type: "object" }];
997
+ if (schema.type !== undefined && schema.type !== "object") return undefined;
998
+ return [{ ...schema, type: "object" }];
706
999
  }
707
1000
 
708
- const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
709
- const branches = obj[compositionKey];
1001
+ const siblings = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== compositionKey));
1002
+ const branches = schema[compositionKey];
710
1003
  if (!Array.isArray(branches)) return undefined;
711
1004
  const expanded: Record<string, unknown>[] = [];
712
1005
  for (const branch of branches) {
713
1006
  const variants = expandXaiRootObjectSchemas(branch);
714
1007
  if (!variants) return undefined;
715
- for (const variant of variants) expanded.push({ ...siblings, ...variant });
1008
+ for (const variant of variants) expanded.push(composeXaiObjectSchemas(siblings, variant));
716
1009
  }
717
1010
  return expanded.length > 0 ? expanded : undefined;
718
1011
  }
719
1012
 
1013
+ function mergeXaiPropertySchemas(values: unknown[]): unknown {
1014
+ const unique: unknown[] = [];
1015
+ const serialized = new Set<string>();
1016
+ for (const value of values) {
1017
+ const key = JSON.stringify(value);
1018
+ if (serialized.has(key)) continue;
1019
+ serialized.add(key);
1020
+ unique.push(value);
1021
+ }
1022
+ return unique.length === 1 ? unique[0] : { anyOf: unique };
1023
+ }
1024
+
1025
+ /**
1026
+ * The Grok CLI proxy rejects a function parameter schema whose root remains oneOf/anyOf.
1027
+ * Flatten only when the merge is lossless: local $refs resolve, every variant is a concrete
1028
+ * object whose keys we can preserve, required sets match, additionalProperties does not change
1029
+ * meaning, every property name exists on every variant, and at most one property schema
1030
+ * differs. Otherwise omit the tool rather than emit a weaker schema.
1031
+ */
720
1032
  function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
721
- const variants = expandXaiRootObjectSchemas(parameters);
1033
+ if (!isXaiObjectSchema(parameters)) return undefined;
1034
+ const resolved = resolveXaiSchemaRefs(parameters, parameters);
1035
+ if (!isXaiObjectSchema(resolved)) return undefined;
1036
+ const variants = expandXaiRootObjectSchemas(resolved);
722
1037
  if (!variants) return undefined;
723
- if (variants.length === 1) return variants[0];
724
- const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
725
- ? parameters as Record<string, unknown>
726
- : {};
727
- const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
728
- return { ...metadata, oneOf: variants };
1038
+ if (variants.length === 1) {
1039
+ return xaiVariantIsConcreteObject(variants[0]) ? variants[0] : undefined;
1040
+ }
1041
+ if (!variants.every(xaiVariantIsConcreteObject) || !xaiRequiredSetsMatch(variants)) return undefined;
1042
+ const additionalProperties = mergeXaiAdditionalProperties(variants);
1043
+ if (!additionalProperties.ok) return undefined;
1044
+ if (!xaiPropertyMergeIsLossless(variants)) return undefined;
1045
+
1046
+ const metadata = Object.fromEntries(Object.entries(resolved).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
1047
+ delete metadata.properties;
1048
+ delete metadata.required;
1049
+ delete metadata.additionalProperties;
1050
+
1051
+ const propertyValues = new Map<string, unknown[]>();
1052
+ for (const variant of variants) {
1053
+ if (!variant.properties || typeof variant.properties !== "object" || Array.isArray(variant.properties)) continue;
1054
+ for (const [name, value] of Object.entries(variant.properties as Record<string, unknown>)) {
1055
+ const values = propertyValues.get(name) ?? [];
1056
+ values.push(value);
1057
+ propertyValues.set(name, values);
1058
+ }
1059
+ }
1060
+ const properties = Object.fromEntries(
1061
+ [...propertyValues].map(([name, values]) => [name, mergeXaiPropertySchemas(values)]),
1062
+ );
1063
+ const required = stringRequiredFields(variants[0]?.required);
1064
+
1065
+ return {
1066
+ ...metadata,
1067
+ type: "object",
1068
+ properties,
1069
+ ...(required.length > 0 ? { required } : {}),
1070
+ ...("value" in additionalProperties ? { additionalProperties: additionalProperties.value } : {}),
1071
+ };
729
1072
  }
730
1073
 
731
1074
  function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
@@ -825,10 +1168,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
825
1168
  formatErrorBody: formatOpenAIChatErrorBody,
826
1169
 
827
1170
  buildRequest(parsed: OcxParsedRequest) {
828
- const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
829
- if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
830
- throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
831
- }
1171
+ const { url, headers, hasCredential } = openAIChatTransport(provider);
832
1172
 
833
1173
  const messages = messagesToChatFormat(parsed, provider);
834
1174
  const tools = toolsToChatFormatForProvider(parsed, provider);
@@ -844,11 +1184,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
844
1184
  // an explicit value here makes the Responses parser's serviceTier projection ineffective.
845
1185
  //
846
1186
  // Opt-in, like `prompt_cache_key` directly below: `service_tier` is an OpenAI-specific
847
- // extension and 66 registry providers share this adapter, several of which reject
848
- // unknown body fields. Forwarding unconditionally would turn a caller-supplied
849
- // `service_tier` into an upstream 400 on those routes. `supportsServiceTier` is the
850
- // Responses-wire flag (applyServiceTierGate) and deliberately does not gate this path.
851
- if (provider.chatServiceTier && parsed.options.serviceTier !== undefined) {
1187
+ // extension and 66 registry providers share this adapter. A provider-wide Chat opt-in
1188
+ // authorizes undeclared models; an exact model declaration can authorize or deny one
1189
+ // model. Provider-level false remains fail-closed.
1190
+ if (canSerializeServiceTierForChatModel(provider, parsed.modelId)
1191
+ && parsed.options.serviceTier !== undefined) {
852
1192
  body.service_tier = parsed.options.serviceTier;
853
1193
  }
854
1194
  if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true;
@@ -981,11 +1321,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
981
1321
  }
982
1322
  if (parsed.stream) body.stream_options = { include_usage: true };
983
1323
 
984
- const url = openaiChatCompletionsUrl(provider.baseUrl);
985
- const headers: Record<string, string> = { "Content-Type": "application/json" };
986
- if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
987
- if (provider.headers) Object.assign(headers, provider.headers);
988
-
989
1324
  const bodyJson = JSON.stringify(body);
990
1325
  if (isDebugEnabled()) {
991
1326
  let host = "upstream";