@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -24,7 +24,8 @@ import {
24
24
  type IntegrationClientId,
25
25
  } from "../../integrations/registry";
26
26
  import { detectRaycast, type RaycastInstall } from "../../integrations/raycast-detect";
27
- import { readIntegrationState } from "../../integrations/state";
27
+ import { readIntegrationState, readOwnedDroidReasoningDefaults } from "../../integrations/state";
28
+ import { droidReasoningModels, validateDroidReasoningDefaults } from "../../clients/config-export";
28
29
  import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store";
29
30
  import {
30
31
  applyIntegrationCoordinated,
@@ -74,6 +75,10 @@ export type IntegrationStateEnvelope = {
74
75
  * the shared `IntegrationStatus`, which describes the file, not the app.
75
76
  */
76
77
  raycast?: RaycastInstall;
78
+ droidReasoning?: {
79
+ models: Array<{ model: string; label: string; efforts: string[] }>;
80
+ defaults: Record<string, string>;
81
+ };
77
82
  } & IntegrationStateRecord;
78
83
 
79
84
  export interface IntegrationStateListEnvelope {
@@ -241,12 +246,14 @@ async function buildIntegrationWriteInput(
241
246
  clientId: IntegrationClientId,
242
247
  ctx: ManagementContext,
243
248
  store: IntegrationStateStore,
249
+ droidReasoningDefaults?: Record<string, string>,
244
250
  ): Promise<IntegrationWriteInput> {
245
251
  return {
246
252
  clientId,
247
253
  models: await loadExportModels(ctx.config),
248
254
  config: ctx.config,
249
255
  port: Number(ctx.url.port) || ctx.config.port,
256
+ ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }),
250
257
  store,
251
258
  io: integrationMutationTestHooks?.io,
252
259
  ...pathOverrides(),
@@ -265,6 +272,7 @@ async function buildIntegrationPreviewInput(
265
272
  clientId: IntegrationClientId,
266
273
  ctx: ManagementContext,
267
274
  store: IntegrationStateStore,
275
+ droidReasoningDefaults?: Record<string, string>,
268
276
  ): Promise<{ input: IntegrationWriteInput; identity: string } | null> {
269
277
  const snapshot = previewExportSnapshot(ctx.config);
270
278
  // No cached roster means no honest snapshot to plan against. Gathering one here would make a
@@ -277,6 +285,7 @@ async function buildIntegrationPreviewInput(
277
285
  models: snapshot.models,
278
286
  config: ctx.config,
279
287
  port: Number(ctx.url.port) || ctx.config.port,
288
+ ...(droidReasoningDefaults === undefined ? {} : { droidReasoningDefaults: { ...droidReasoningDefaults } }),
280
289
  store,
281
290
  io: integrationMutationTestHooks?.io,
282
291
  ...pathOverrides(),
@@ -309,6 +318,31 @@ function invalidClientResponse(ctx: ManagementContext): Response {
309
318
  }, 400, ctx.req, ctx.config);
310
319
  }
311
320
 
321
+ function isStringRecord(value: unknown): value is Record<string, string> {
322
+ return value !== null && typeof value === "object" && !Array.isArray(value)
323
+ && Object.values(value).every(item => typeof item === "string");
324
+ }
325
+
326
+ function invalidDroidDefaultsResponse(ctx: ManagementContext, message: string): Response {
327
+ return jsonResponse({ error: message, code: "invalid_droid_reasoning_defaults" }, 400, ctx.req, ctx.config);
328
+ }
329
+
330
+ function readDroidReasoningDefaults(
331
+ ctx: ManagementContext,
332
+ body: Record<string, unknown>,
333
+ clientId: string,
334
+ operation: IntegrationPlanOperation,
335
+ ): Record<string, string> | Response | undefined {
336
+ if (!Object.hasOwn(body, "droidReasoningDefaults")) return undefined;
337
+ if (clientId !== "droid" || (operation !== "apply" && operation !== "overwrite")) {
338
+ return invalidDroidDefaultsResponse(ctx, "droidReasoningDefaults is allowed only for Droid apply or overwrite");
339
+ }
340
+ const defaults = body.droidReasoningDefaults;
341
+ return isStringRecord(defaults)
342
+ ? defaults
343
+ : invalidDroidDefaultsResponse(ctx, "droidReasoningDefaults must map model names to effort strings");
344
+ }
345
+
312
346
  /**
313
347
  * No cached model roster, so there is nothing honest to plan against.
314
348
  *
@@ -364,6 +398,7 @@ function stalePlanGuard(
364
398
  request: PreviewRequest,
365
399
  fingerprint: string,
366
400
  capturedIdentity: string | null,
401
+ droidReasoningDefaults?: Record<string, string>,
367
402
  ): {
368
403
  revalidate: NonNullable<CoordinatedIntegrationOptions["revalidate"]>;
369
404
  response: () => Response | null;
@@ -379,7 +414,7 @@ function stalePlanGuard(
379
414
  * without ever swapping the roster this mutation is about to use.
380
415
  */
381
416
  if (exportSnapshotIdentity(ctx.config) !== capturedIdentity) {
382
- const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store);
417
+ const refreshed = await buildIntegrationPreviewInput(clientId, ctx, store, droidReasoningDefaults);
383
418
  stale = refreshed === null ? "unavailable" : previewIntegration(refreshed.input, request);
384
419
  return { ok: false, reason: "conflict", state: "conflict", clientId, message: "the model roster changed while confirming" };
385
420
  }
@@ -723,9 +758,17 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
723
758
  code: "invalid_preview_operation",
724
759
  }, 400, req, ctx.config);
725
760
  }
761
+ const droidDefaults = readDroidReasoningDefaults(ctx, parsed, previewClient, operation);
762
+ if (droidDefaults instanceof Response) return droidDefaults;
726
763
  try {
727
- const captured = await buildIntegrationPreviewInput(previewClient as IntegrationClientId, ctx, integrationStore());
764
+ const captured = await buildIntegrationPreviewInput(
765
+ previewClient as IntegrationClientId, ctx, integrationStore(), droidDefaults,
766
+ );
728
767
  if (!captured) return previewUnavailableResponse(ctx);
768
+ if (droidDefaults !== undefined) {
769
+ const invalid = validateDroidReasoningDefaults(captured.input.models, droidDefaults);
770
+ if (invalid) return invalidDroidDefaultsResponse(ctx, invalid);
771
+ }
729
772
  return jsonResponse(previewIntegration(captured.input, { operation }), 200, req, ctx.config);
730
773
  } catch (error) {
731
774
  return internalErrorResponse(error, ctx);
@@ -907,7 +950,15 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
907
950
  // process spawn, and no other client's read should pay for it.
908
951
  const envelope: IntegrationStateEnvelope = requestedClient === "raycast"
909
952
  ? { ...state, raycast: (raycastDetectTestHook ?? detectRaycast)() }
910
- : state;
953
+ : requestedClient === "droid"
954
+ ? {
955
+ ...state,
956
+ droidReasoning: {
957
+ models: droidReasoningModels(input.models),
958
+ defaults: readOwnedDroidReasoningDefaults(input),
959
+ },
960
+ }
961
+ : state;
911
962
  return jsonResponse(envelope, 200, req, ctx.config);
912
963
  } catch (error) {
913
964
  return internalErrorResponse(error, ctx);
@@ -944,6 +995,8 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
944
995
  const requestedOperation: IntegrationPlanOperation = parsed.enabled
945
996
  ? (parsed.overwriteConflict === true ? "overwrite" : "apply")
946
997
  : "disable";
998
+ const droidDefaults = readDroidReasoningDefaults(ctx, parsed, requestedClient, requestedOperation);
999
+ if (droidDefaults instanceof Response) return droidDefaults;
947
1000
  const binding = planBindingOf(parsed);
948
1001
  if (binding === "half") return halfBoundResponse(ctx);
949
1002
  if (binding === "unknown-operation" || (binding !== "none" && binding.operation !== requestedOperation)) {
@@ -964,11 +1017,15 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
964
1017
  */
965
1018
  const boundToggle = binding === "none"
966
1019
  ? null
967
- : await buildIntegrationPreviewInput(requestedClient, ctx, integrationStore());
1020
+ : await buildIntegrationPreviewInput(requestedClient, ctx, integrationStore(), droidDefaults);
968
1021
  if (binding !== "none" && boundToggle === null) return previewUnavailableResponse(ctx);
969
1022
  const input = boundToggle
970
1023
  ? boundToggle.input
971
- : await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
1024
+ : await buildIntegrationWriteInput(requestedClient, ctx, integrationStore(), droidDefaults);
1025
+ if (droidDefaults !== undefined) {
1026
+ const invalid = validateDroidReasoningDefaults(input.models, droidDefaults);
1027
+ if (invalid) return invalidDroidDefaultsResponse(ctx, invalid);
1028
+ }
972
1029
  const guard = binding === "none" ? null : stalePlanGuard(
973
1030
  requestedClient,
974
1031
  ctx,
@@ -976,6 +1033,7 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
976
1033
  { operation: binding.operation },
977
1034
  binding.fingerprint,
978
1035
  boundToggle === null ? null : boundToggle.identity,
1036
+ droidDefaults,
979
1037
  );
980
1038
  const result = await runIntegrationMutationFlight(
981
1039
  requestedClient,
@@ -86,7 +86,7 @@ function port(value: unknown): value is number {
86
86
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535;
87
87
  }
88
88
 
89
- /** A paired GUI session. Only hub runtimes issue these. */
89
+ /** A GUI session redeemed from an operator-created, one-use pairing grant. */
90
90
  function pairedSession(ctx: ManagementContext): boolean {
91
91
  return ctx.principal === "gui-session"
92
92
  && ctx.sessionControl?.isPaired(ctx.req, ctx.config) === true;
@@ -96,9 +96,8 @@ function pairedSession(ctx: ManagementContext): boolean {
96
96
  * A paired session, or on a standalone runtime the current loopback-issued session that reached
97
97
  * the public listener bound to a loopback hostname. The loopback bootstrap mints that session
98
98
  * without a credential, so this is casual-path protection like POST /api/github/star, not a
99
- * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Join admits this
100
- * session too: turning a Child on from its own dashboard is the point of the route, and the
101
- * dashboard warns first that the restart briefly interrupts running Codex turns.
99
+ * secret-backed boundary like the admin token. Hubs keep the paired-only rule. Durable operations
100
+ * that require operator approval, such as joining this machine as a Child, use pairedSession.
102
101
  */
103
102
  function dashboardSession(ctx: ManagementContext): boolean {
104
103
  if (pairedSession(ctx)) return true;
@@ -113,9 +112,10 @@ function adminLoopback(ctx: ManagementContext): boolean {
113
112
  return ctx.principal === "admin-token" && ctx.trustedLoopbackIngress;
114
113
  }
115
114
 
116
- function auth(ctx: ManagementContext, kind: "dashboard" | "admin" | "either"): Response | null {
115
+ function auth(ctx: ManagementContext, kind: "dashboard" | "paired" | "admin" | "either"): Response | null {
117
116
  if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
118
117
  const allowed = kind === "dashboard" ? dashboardSession(ctx)
118
+ : kind === "paired" ? pairedSession(ctx)
119
119
  : kind === "admin" ? adminLoopback(ctx)
120
120
  : dashboardSession(ctx) || adminLoopback(ctx);
121
121
  return allowed ? null : fail("forbidden", "The required link authorization was not present.", 403);
@@ -131,9 +131,14 @@ function joinPortMatches(ctx: ManagementContext): boolean {
131
131
  return live !== undefined && live === ctx.config.port;
132
132
  }
133
133
 
134
- /** Whether `POST /api/link/join` would pass its admission, role and port gates for this caller. */
135
- function joinAvailable(ctx: ManagementContext): boolean {
136
- return dashboardSession(ctx) && (ctx.config.runtimeRole ?? "standalone") === "standalone" && joinPortMatches(ctx);
134
+ type LinkJoinDenied = "pairing_required" | "standalone_required" | "join_port_mismatch";
135
+
136
+ /** Read-only explanation of the join gates, in the same order as `POST /api/link/join`. */
137
+ function joinDenied(ctx: ManagementContext): LinkJoinDenied | null {
138
+ if (!pairedSession(ctx)) return "pairing_required";
139
+ if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return "standalone_required";
140
+ if (!joinPortMatches(ctx)) return "join_port_mismatch";
141
+ return null;
137
142
  }
138
143
 
139
144
  function runnerFor(ctx: ManagementContext): SshRunner {
@@ -549,9 +554,9 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
549
554
  if (!isLinkPath(path)) return null;
550
555
  if (ctx.guiSessionIssuance === "tailscale-identity") return fail("tailscale_session_refused", "Tailscale identity sessions cannot use link routes.", 403);
551
556
  if (url.pathname === "/api/link/join" && req.method === "POST") {
552
- // The same dashboard admission as the Home side. Every refusal below runs before link state
553
- // is read and before any SSH.
554
- const denied = auth(ctx, "dashboard");
557
+ // Joining durably redirects local client traffic, so a credentiallessly bootstrapped loopback
558
+ // session is insufficient. Every refusal below runs before link state and before any SSH.
559
+ const denied = auth(ctx, "paired");
555
560
  if (denied) return denied;
556
561
  if ((ctx.config.runtimeRole ?? "standalone") !== "standalone") return fail("standalone_required", "Client initiated links require standalone runtime mode.", 409);
557
562
  if (!joinPortMatches(ctx)) return fail("join_port_mismatch", "OpenCodex is not running on its configured port, so it cannot restart as a Child.", 409);
@@ -578,9 +583,11 @@ export async function handleLinkRoutes(ctx: ManagementContext, suppliedState?: L
578
583
  const failure = state.compensationFailures?.get(store.links[0].id);
579
584
  if (failure) Object.assign(dto.child, { state: "failed" as const, since: failure.since, reason: failure.reason });
580
585
  }
581
- // A dashboard session also learns whether it may join as a Child. The admin-token answer stays
586
+ // A dashboard session also learns why it cannot join as a Child. The admin-token answer stays
582
587
  // the exact K16 document that `ocx link status` validates key by key.
583
- const body: LinkStatusDto & { joinAvailable?: boolean } = ctx.principal === "gui-session" ? { ...dto, joinAvailable: joinAvailable(ctx) } : dto;
588
+ const denial = ctx.principal === "gui-session" ? joinDenied(ctx) : null;
589
+ const body: LinkStatusDto & { joinAvailable?: boolean; joinDenied?: LinkJoinDenied | null } = ctx.principal === "gui-session"
590
+ ? { ...dto, joinAvailable: denial === null, joinDenied: denial } : dto;
584
591
  return Response.json(body, { headers: { "cache-control": "no-store" } });
585
592
  }
586
593
  if (url.pathname === "/api/link/candidates" && req.method === "GET") {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Effective export metadata for custom `/api/models` rows.
3
+ *
4
+ * A custom row's own fields are the operator's stored OVERRIDE. `/api/models` returns them
5
+ * verbatim because the edit dialog must show what the user set — including an explicit empty
6
+ * "no reasoning" ladder — not what the provider happens to advertise today. But a client
7
+ * export has to serialize what the proxy will actually route: the canonical resolved catalog
8
+ * metadata for the same `provider/modelId`. `listManagementModelRows` resolves that
9
+ * projection here and attaches it additively, so the editor's raw fields and the exporter's
10
+ * effective fields can never overwrite each other.
11
+ *
12
+ * Sources are the ones the routed-row projection already uses — the gathered catalog row for
13
+ * the same slug (the gather materializes custom rows with provider/native inheritance), the
14
+ * registry-enriched provider config, and the ladder resolution in `model-rows.ts`. No
15
+ * independent provider fact tables, and no per-model fetch: everything comes from the roster
16
+ * and config already in hand.
17
+ */
18
+ import { enrichProviderFromRegistry } from "../../providers/derive";
19
+ import { modelRecordValue } from "../../reasoning-effort";
20
+ import { configuredReasoningSummarySupport } from "../../codex/catalog/model-hints";
21
+ import type { CatalogModel } from "../../codex/catalog";
22
+ import type { OcxConfig } from "../../types";
23
+ import {
24
+ knownReasoningSupport,
25
+ knownToolsSupport,
26
+ type EffectiveModelExportMetadata,
27
+ } from "../../clients/config-export/contracts";
28
+
29
+ /** The stored-override fields a custom row carries, exactly as `config.customModels` writes them. */
30
+ export interface CustomRowOverrideSource {
31
+ provider: string;
32
+ id: string;
33
+ contextWindow?: number;
34
+ inputModalities?: string[];
35
+ /** Explicit ladder; `[]` is a declaration ("no rungs"), not an absence. */
36
+ reasoningEfforts?: string[];
37
+ defaultReasoningEffort?: string;
38
+ }
39
+
40
+ /**
41
+ * Resolve the effective export metadata for one custom row.
42
+ *
43
+ * Explicit custom overrides win; resolved gathered facts precede provider-config fallbacks.
44
+ * `effectiveReasoningEfforts` is
45
+ * supplied by the caller because its resolution (declared ladder first, then the enriched
46
+ * provider maps, then the pinned native table, then the catalog hints) lives with the other
47
+ * ladder readers in `model-rows.ts`; here it is an input, resolved once per row.
48
+ */
49
+ export function customRowExportMetadata(
50
+ config: OcxConfig,
51
+ source: CustomRowOverrideSource,
52
+ catalogRow: CatalogModel | undefined,
53
+ effectiveReasoningEfforts: string[] | undefined,
54
+ ): EffectiveModelExportMetadata {
55
+ const provider = config.providers[source.provider];
56
+ // The same registry hydration the other per-model readers at this boundary perform, so a
57
+ // custom row whose provider declares facts only in the registry still inherits them. The
58
+ // per-model records are cloned first because the enrichment fills and folds them in place.
59
+ const enriched = provider === undefined ? undefined : {
60
+ ...provider,
61
+ ...(provider.modelContextWindows ? { modelContextWindows: { ...provider.modelContextWindows } } : {}),
62
+ ...(provider.modelInputModalities ? { modelInputModalities: { ...provider.modelInputModalities } } : {}),
63
+ ...(provider.modelCapabilities ? { modelCapabilities: { ...provider.modelCapabilities } } : {}),
64
+ ...(provider.modelReasoningEfforts ? { modelReasoningEfforts: { ...provider.modelReasoningEfforts } } : {}),
65
+ ...(provider.modelDefaultReasoningEfforts
66
+ ? { modelDefaultReasoningEfforts: { ...provider.modelDefaultReasoningEfforts } }
67
+ : {}),
68
+ };
69
+ if (enriched) enrichProviderFromRegistry(source.provider, enriched);
70
+
71
+ // Gathered resolved context first after the explicit override: the gather already ran the
72
+ // canonical precedence (operator maps, registry, native clamps) and may have applied a
73
+ // tighter cap than a config map read here would suggest. Re-reading the config maps ahead
74
+ // of it would duplicate that policy and could widen past an observed tighter window.
75
+ const contextWindow = source.contextWindow
76
+ ?? catalogRow?.contextWindow
77
+ ?? modelRecordValue(enriched?.modelContextWindows, source.id)
78
+ ?? enriched?.contextWindow;
79
+ // Same declaration chain `declaredModelInputModalities` reads, as the fallback when neither
80
+ // the override nor the gathered row says anything.
81
+ const declaredModalities = enriched === undefined ? undefined : (
82
+ enriched.modelCapabilities?.[source.id]?.inputModalities
83
+ ?? modelRecordValue(enriched.modelInputModalities, source.id)
84
+ );
85
+ const inputModalities = source.inputModalities ?? catalogRow?.inputModalities ?? declaredModalities;
86
+ // The gathered row carries what the materializer already resolved; for a rosterless row the
87
+ // same reader the materializer uses answers from the provider config, so both paths agree.
88
+ const supportsReasoningSummaries = catalogRow?.supportsReasoningSummaries
89
+ ?? configuredReasoningSummarySupport(provider, source.id);
90
+ const defaultReasoningEffort = declaredDefaultWithin(
91
+ effectiveReasoningEfforts,
92
+ defaultEffort(source, catalogRow, provider),
93
+ );
94
+ return {
95
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
96
+ ...(catalogRow?.maxInputTokens !== undefined ? { maxInputTokens: catalogRow.maxInputTokens } : {}),
97
+ ...(catalogRow?.maxOutputTokens !== undefined ? { maxTokens: catalogRow.maxOutputTokens } : {}),
98
+ ...(inputModalities !== undefined ? { inputModalities: [...inputModalities] } : {}),
99
+ ...(effectiveReasoningEfforts !== undefined ? { reasoningEfforts: [...effectiveReasoningEfforts] } : {}),
100
+ ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}),
101
+ ...(knownToolsSupport({
102
+ capabilities: catalogRow?.capabilities,
103
+ parallelToolCalls: catalogRow?.parallelToolCalls,
104
+ }) === true ? { supportsTools: true } : {}),
105
+ ...(knownReasoningSupport({ reasoningEfforts: effectiveReasoningEfforts, supportsReasoningSummaries }) === true
106
+ ? { supportsReasoning: true }
107
+ : {}),
108
+ ...(supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries } : {}),
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The declared default, suppressed unless it is a member of the effective ladder. An empty
114
+ * ladder has no rung to default to, and a default the ladder does not contain describes an
115
+ * effort the proxy would never send — the same rule the settings route applies when it
116
+ * rejects a default "not in the declared reasoningEfforts ladder", applied here so an export
117
+ * never ships a default the row cannot act on.
118
+ */
119
+ function declaredDefaultWithin(
120
+ effectiveReasoningEfforts: string[] | undefined,
121
+ declared: string | undefined,
122
+ ): string | undefined {
123
+ if (declared === undefined) return undefined;
124
+ if (effectiveReasoningEfforts === undefined) return declared;
125
+ return effectiveReasoningEfforts.includes(declared) ? declared : undefined;
126
+ }
127
+
128
+ /**
129
+ * The declared default, and nothing else. The ladder-preference fallback
130
+ * (`effectiveModelDefaultReasoningEffort`'s medium → low → first) fabricates a default no
131
+ * source declared; it is right for the picker's own preference order but wrong for an export
132
+ * that claims to carry provider truth, so this projection stops at:
133
+ *
134
+ * 1. the explicit custom default (an override always wins),
135
+ * 2. the gathered catalog row's declared default (the gather only lets it ride when it is a
136
+ * member of the effective ladder),
137
+ * 3. the operator's per-model record — the same raw map the routed-row reader consults.
138
+ *
139
+ * `declaredDefaultWithin` then drops the answer when it is not a member of the effective
140
+ * ladder, empty ladder included.
141
+ */
142
+ function defaultEffort(
143
+ source: CustomRowOverrideSource,
144
+ catalogRow: CatalogModel | undefined,
145
+ provider: { modelDefaultReasoningEfforts?: Record<string, string> } | undefined,
146
+ ): string | undefined {
147
+ if (source.defaultReasoningEffort) return source.defaultReasoningEffort;
148
+ if (catalogRow?.defaultReasoningEffort) return catalogRow.defaultReasoningEffort;
149
+ return modelRecordValue(provider?.modelDefaultReasoningEfforts, source.id) ?? undefined;
150
+ }
@@ -9,6 +9,7 @@
9
9
  * Bodies are unchanged from their previous home; only `export` was added.
10
10
  */
11
11
  import type { CatalogModel } from "../../codex/catalog";
12
+ import { antigravityFamilyDisabled } from "../../providers/antigravity-effort-families";
12
13
  import { observeModelCacheRevision } from "../../codex/model-cache";
13
14
  import {
14
15
  captureExportConfigAdmission,
@@ -30,7 +31,13 @@ import {
30
31
  catalogModelEfforts,
31
32
  } from "../../codex/catalog";
32
33
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../codex/catalog/native-models";
33
- import type { ExportModel } from "../../clients/config-export";
34
+ import {
35
+ knownReasoningSupport,
36
+ knownToolsSupport,
37
+ type EffectiveModelExportMetadata,
38
+ type ExportModel,
39
+ } from "../../clients/config-export/contracts";
40
+ import { customRowExportMetadata } from "./model-row-export-metadata";
34
41
  import { providerContextCap } from "../../providers/context-cap";
35
42
  import { isVisionReasoningEffort, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
36
43
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
@@ -78,6 +85,28 @@ export type ManagementModelRow = Partial<CatalogModel> & {
78
85
  * config — so this compares values against what the model would inherit anyway.
79
86
  */
80
87
  reasoningOverridden?: boolean;
88
+ /**
89
+ * Tool support a canonical source positively asserts (native identity, a catalog row's
90
+ * `"tools"` capability, or the parallel-call opt-in — the same evidence
91
+ * `src/routing/capability.ts` counts). A row without positive evidence stays absent:
92
+ * unknown is not false.
93
+ */
94
+ supportsTools?: boolean;
95
+ /**
96
+ * True when a canonical source positively asserts reasoning (a non-empty effective ladder
97
+ * or delivered reasoning summaries). Absent means unknown, never "cannot reason": an empty
98
+ * ladder only says the effort is not adjustable.
99
+ */
100
+ supportsReasoning?: boolean;
101
+ /**
102
+ * Effective export projection for a row, resolved from canonical sources only — for a
103
+ * custom row the full effective view (the raw override fields above stay exactly what the
104
+ * editor wrote), for routed and native rows the authoritative declared default where the
105
+ * row's own `defaultReasoningEffort` is the picker's preference order instead. Additive on
106
+ * `/api/models`, and its PRESENCE is the authority signal: an absent field inside it means
107
+ * "no declared value", never "fall back to the row".
108
+ */
109
+ exportMetadata?: EffectiveModelExportMetadata;
81
110
  };
82
111
 
83
112
  /** Resolve the exact text and source shown for one routed discovered model. */
@@ -322,6 +351,13 @@ export async function listManagementModelRows(
322
351
  ...(row.autoCompactTokenLimit !== undefined
323
352
  ? { autoCompactTokenLimit: row.autoCompactTokenLimit }
324
353
  : {}),
354
+ // The pinned default above IS the declared default for a native row — no heuristic
355
+ // touches it — so the authoritative projection carries it verbatim and exists even
356
+ // when empty, which is what tells an export consumer "absence means no default"
357
+ // rather than "fall back to the row".
358
+ exportMetadata: {
359
+ ...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}),
360
+ },
325
361
  };
326
362
  });
327
363
  const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => {
@@ -374,6 +410,34 @@ export async function listManagementModelRows(
374
410
  // the guard and the value) re-read the same file thousands of times on a large roster.
375
411
  const routedSlugs = publicModels.filter(m => m.provider !== "combo").map(m => routedSlug(m.provider, m.id));
376
412
  const catalogLadders = catalogModelEfforts([...new Set(routedSlugs)]);
413
+ // The gathered roster, keyed once by Codex-facing slug. A custom row looks itself up here —
414
+ // the gather materializes custom rows with the provider/native inheritance the catalog
415
+ // publishes, so this is the canonical resolved metadata the export must preserve, and no
416
+ // per-model fetch ever happens to obtain it.
417
+ const rosterByNamespaced = new Map(publicModels.map(model => [catalogModelSlug(model), model] as const));
418
+ const customRowsWithExportMetadata = visibleCustomModels.map(model => {
419
+ const catalogRow = rosterByNamespaced.get(model.namespaced);
420
+ // The gathered materialization's ladder is authoritative when it has one: it already
421
+ // folded the explicit override in and then applied the canonical bounds (the native
422
+ // clamp for a capability-backed id, learned refusals), so re-declaring the raw override
423
+ // here would undo a bound. Two raw values still win: the explicit empty ladder — "no
424
+ // rungs" is an operator statement no normalization may fill in, even against a stale
425
+ // gathered row — and any override with no gathered ladder behind it.
426
+ const declaredEfforts = Array.isArray(model.reasoningEfforts) && model.reasoningEfforts.length === 0
427
+ ? model.reasoningEfforts
428
+ : catalogRow?.reasoningEfforts !== undefined
429
+ ? catalogRow.reasoningEfforts
430
+ : model.reasoningEfforts;
431
+ return {
432
+ ...model,
433
+ exportMetadata: customRowExportMetadata(
434
+ config,
435
+ model,
436
+ catalogRow,
437
+ effectiveModelReasoningEfforts(config, model.provider, model.id, declaredEfforts, catalogLadders),
438
+ ),
439
+ };
440
+ });
377
441
  const dedupedRouted = publicModels.map((m): ManagementModelRow | null => {
378
442
  // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
379
443
  const namespaced = catalogModelSlug(m);
@@ -395,7 +459,7 @@ export async function listManagementModelRows(
395
459
  ...m,
396
460
  ...displayName,
397
461
  namespaced,
398
- disabled: [...disabled].some(stored => (
462
+ disabled: antigravityFamilyDisabled(config, m, models) || [...disabled].some(stored => (
399
463
  (!nativeAlias && stored === namespaced) || slugEquals(stored, m.provider, m.id)
400
464
  )),
401
465
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
@@ -407,13 +471,25 @@ export async function listManagementModelRows(
407
471
  ...(routed ? { reasoningOverridden: reasoningOverrideFor(config, m.provider, m.id, catalogLadders) } : {}),
408
472
  ...(inputModalitiesDeclared !== undefined ? { inputModalitiesDeclared } : {}),
409
473
  ...(contextWindowDeclared !== undefined ? { contextWindowDeclared } : {}),
474
+ // The authoritative export default is the GATHERED row's own declared default — never
475
+ // the picker value above, whose medium → low → first fallback invents a default no
476
+ // source declared. The object exists even when empty: its presence is what tells
477
+ // `toExportModel` and the CLI that an absent default means "no default", not "fall
478
+ // back to the row". A default outside the effective ladder is dropped for the same
479
+ // reason the settings route rejects one: the proxy would never send it.
480
+ exportMetadata: {
481
+ ...(m.defaultReasoningEffort !== undefined
482
+ && (reasoningEfforts === undefined || reasoningEfforts.includes(m.defaultReasoningEffort))
483
+ ? { defaultReasoningEffort: m.defaultReasoningEffort }
484
+ : {}),
485
+ },
410
486
  };
411
487
  }).filter((row): row is ManagementModelRow => row !== null);
412
488
  // Manual OpenAI rows retain their routed selector but replace the bare dashboard row.
413
489
  // Account-qualified rows remain distinct, explicitly selected routes.
414
490
  const visibleNative = native.filter(model => model.id.includes("/")
415
491
  || !customNamespaced.has(routedSlug(model.provider, model.id)));
416
- const rows = [...visibleNative, ...dedupedRouted, ...visibleCustomModels];
492
+ const rows = [...visibleNative, ...dedupedRouted, ...customRowsWithExportMetadata];
417
493
  // Include disabled rows and configured aliases before the export visibility filter:
418
494
  // a hidden real `x--fast` must never become a synthetic selector for another model.
419
495
  const knownIds = config.fastRows === false ? new Set<string>() : knownEffortRowIds(config);
@@ -422,6 +498,23 @@ export async function listManagementModelRows(
422
498
  const pending = initialModelSelectionPending(config.providers[row.provider]);
423
499
  const modelCosts = Object.hasOwn(config.providers, row.provider)
424
500
  ? config.providers[row.provider]?.modelCosts : undefined;
501
+ // Derived once here so the wire row carries the same booleans the export projection
502
+ // serializes; the CLI reads /api/models and cannot re-derive them without duplicating
503
+ // this policy. Custom rows read their effective view from `exportMetadata`; routed and
504
+ // native rows read their own evidence.
505
+ const supportsTools = row.exportMetadata?.supportsTools
506
+ ?? row.supportsTools
507
+ ?? knownToolsSupport({
508
+ capabilities: row.capabilities,
509
+ parallelToolCalls: row.parallelToolCalls,
510
+ native: row.native,
511
+ });
512
+ const supportsReasoning = row.exportMetadata?.supportsReasoning
513
+ ?? row.supportsReasoning
514
+ ?? knownReasoningSupport({
515
+ reasoningEfforts: row.reasoningEfforts,
516
+ supportsReasoningSummaries: row.supportsReasoningSummaries,
517
+ });
425
518
  return {
426
519
  ...row,
427
520
  ...(!row.native && modelCosts !== undefined && Object.hasOwn(modelCosts, row.id)
@@ -429,12 +522,52 @@ export async function listManagementModelRows(
429
522
  ...(pending ? { disabled: true, initialSelectionPending: true } : {}),
430
523
  fastRowAvailable: !row.disabled && !pending
431
524
  && !knownIds.has(fastRowId(row.namespaced)) && catalogFastRowEligible(config, row),
525
+ // An explicit false survives; derivation itself is true-or-nothing.
526
+ ...(typeof supportsTools === "boolean" ? { supportsTools } : {}),
527
+ ...(typeof supportsReasoning === "boolean" ? { supportsReasoning } : {}),
432
528
  };
433
529
  });
434
530
  }
435
531
 
436
- /** `/api/models` row → the narrower input the client-config serializers accept. */
532
+ /**
533
+ * `/api/models` row → the narrower input the client-config serializers accept.
534
+ *
535
+ * A row's `exportMetadata`, when present, is the authority: custom rows project through it
536
+ * because their own fields are stored overrides (an absent field means "not overridden"),
537
+ * and routed/native rows carry the declared default in it because the row-level
538
+ * `defaultReasoningEffort` is the picker's preference order, whose medium → low → first
539
+ * fallback fabricates a default no source declared.
540
+ */
437
541
  export function toExportModel(row: ManagementModelRow): ExportModel {
542
+ const effective = row.exportMetadata;
543
+ const contextWindow = effective?.contextWindow ?? row.contextWindow;
544
+ const maxTokens = effective?.maxTokens ?? row.maxOutputTokens;
545
+ const maxInputTokens = effective?.maxInputTokens ?? row.maxInputTokens;
546
+ const inputModalities = effective?.inputModalities ?? row.inputModalities;
547
+ const reasoningEfforts = effective?.reasoningEfforts ?? row.reasoningEfforts;
548
+ // The projection is the authority on the default. When it exists, an absent default means
549
+ // NO declared default — falling back to the row would reintroduce the picker's synthetic
550
+ // medium/low value, which is exactly what this projection exists to keep out of exports.
551
+ // Rows without a projection (native-era shapes, hand-built rows) keep the row's own value.
552
+ const defaultReasoningEffort = effective !== undefined
553
+ ? effective.defaultReasoningEffort
554
+ : row.defaultReasoningEffort;
555
+ const supportsReasoningSummaries = effective?.supportsReasoningSummaries ?? row.supportsReasoningSummaries;
556
+ // Projection first, then the row's own explicit boolean (an external producer may state
557
+ // false, which is evidence), and only then derivation from positive evidence.
558
+ const supportsTools = effective?.supportsTools
559
+ ?? row.supportsTools
560
+ ?? knownToolsSupport({
561
+ capabilities: row.capabilities,
562
+ parallelToolCalls: row.parallelToolCalls,
563
+ native: row.native,
564
+ });
565
+ const supportsReasoning = effective?.supportsReasoning
566
+ ?? row.supportsReasoning
567
+ ?? knownReasoningSupport({
568
+ reasoningEfforts: row.reasoningEfforts,
569
+ supportsReasoningSummaries: row.supportsReasoningSummaries,
570
+ });
438
571
  return {
439
572
  namespaced: row.namespaced,
440
573
  provider: row.provider,
@@ -442,11 +575,17 @@ export function toExportModel(row: ManagementModelRow): ExportModel {
442
575
  fastRowAvailable: row.fastRowAvailable === true,
443
576
  ...(row.native ? { native: true } : {}),
444
577
  ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}),
445
- ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
446
- ...(row.maxOutputTokens !== undefined ? { maxTokens: row.maxOutputTokens } : {}),
447
- ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
448
- ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}),
449
- ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}),
578
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
579
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
580
+ ...(maxInputTokens !== undefined ? { maxInputTokens } : {}),
581
+ ...(inputModalities ? { inputModalities } : {}),
582
+ // An empty array is a declaration ("no rungs"), and `[]` is truthy: it survives here.
583
+ ...(reasoningEfforts ? { reasoningEfforts } : {}),
584
+ ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}),
585
+ // An explicit false is evidence and survives; only derivation is true-or-nothing.
586
+ ...(typeof supportsTools === "boolean" ? { supportsTools } : {}),
587
+ ...(typeof supportsReasoning === "boolean" ? { supportsReasoning } : {}),
588
+ ...(supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries } : {}),
450
589
  };
451
590
  }
452
591