@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DO8liQVL.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-DQ-Ie18T.css +0 -1
  155. package/gui/dist/assets/index-DX0RGj3e.js +0 -112
@@ -215,6 +215,11 @@ export interface ProviderRegistryEntry {
215
215
  * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities.
216
216
  */
217
217
  requiresAdjacentResponsesToolResults?: boolean;
218
+ /**
219
+ * When enabled, tool results that are present but empty are annotated on the wire.
220
+ * Seeded/backfilled like other fixed wire capabilities.
221
+ */
222
+ annotateEmptyToolOutputs?: boolean;
218
223
  /**
219
224
  * Registry default for the provider's `service_tier` support; see
220
225
  * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
@@ -454,6 +459,7 @@ const XAI_MODELS = [
454
459
  "grok-4.6",
455
460
  "grok-4.5",
456
461
  "grok-4.3",
462
+ "grok-4.20-multi-agent-0309",
457
463
  "grok-4.20-0309-reasoning",
458
464
  "grok-4.20-0309-non-reasoning",
459
465
  "grok-build-0.1",
@@ -1148,8 +1154,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1148
1154
  // 260709 refresh: lineup + metadata from official docs.x.ai (grok-4.5 announced 07-08);
1149
1155
  // grok-composer-2.5-fast kept as account-verified (absent from public docs). Evidence:
1150
1156
  // devlog/model_update/260709_model_refresh/001_xai_lineup.md.
1151
- // grok-4.20-multi-agent-0309 is intentionally absent: the OAuth chat-completions
1152
- // transport returns 400 ("Multi Agent requests are not allowed on chat completions").
1157
+ // 260823: grok-4.20-multi-agent-0309 still returns 400 on Chat Completions, but works
1158
+ // on Responses. The server reports this dated id for both it and the floating
1159
+ // grok-4.20-multi-agent-beta-latest alias, so expose only the dated deployment id.
1153
1160
  // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match
1154
1161
  // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung.
1155
1162
  models: XAI_MODELS,
@@ -1165,9 +1172,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1165
1172
  // than the seeded ones do.
1166
1173
  supportsVerbosity: false,
1167
1174
  defaultModel: "grok-4.5",
1168
- // Keep Codex Responses callers on the compatibility Chat wire until xAI can replay
1169
- // opaque reasoning continuation and compaction state across later turns. The scoped
1170
- // declaration also keeps caller-owned service tiers off the OAuth subscription route.
1175
+ // Keep 4.6/4.5 Responses callers on the compatibility Chat wire until xAI can replay
1176
+ // opaque reasoning continuation and compaction state across later turns. Multi-agent has
1177
+ // no Chat wire, so Responses callers use its only working wire under both auth modes.
1178
+ // Caller-owned service tiers stay off the unclassified OAuth subscription route; key-auth
1179
+ // Fast remains proxy-owned and is still selected through keyAuthServiceTier above.
1171
1180
  modelWireDefaults: {
1172
1181
  "grok-4.6": {
1173
1182
  wire: "openai-chat",
@@ -1181,6 +1190,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1181
1190
  authModes: ["oauth"],
1182
1191
  forwardCallerServiceTier: false,
1183
1192
  },
1193
+ "grok-4.20-multi-agent-0309": {
1194
+ // Even at high effort it emits no reasoning-summary deltas or encrypted replay
1195
+ // material. Do not encode that as modelSupportsReasoningSummaries:false: through
1196
+ // Codex #1100 that suppresses the entire reasoning object, including the effort
1197
+ // that controls this model's agent count. An empty summary pane is harmless.
1198
+ // Chat Completions returns 400 for this model, so every inbound uses Responses —
1199
+ // `anthropic` included. Omitting it left providerModelWireDefault returning undefined
1200
+ // for the Claude Messages lane, so resolveWireProtocolOverride kept xAI's provider-wide
1201
+ // openai-chat adapter and sent this model to the wire it 400s on.
1202
+ wire: "openai-responses",
1203
+ inbound: ["responses", "chat", "anthropic"],
1204
+ forwardCallerServiceTier: false,
1205
+ },
1184
1206
  },
1185
1207
  // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
1186
1208
  // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
@@ -1192,6 +1214,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1192
1214
  "grok-4.6": ["text", "image"],
1193
1215
  "grok-4.5": ["text", "image"],
1194
1216
  "grok-4.3": ["text", "image"],
1217
+ "grok-4.20-multi-agent-0309": ["text", "image"],
1195
1218
  "grok-4.20-0309-reasoning": ["text", "image"],
1196
1219
  "grok-4.20-0309-non-reasoning": ["text", "image"],
1197
1220
  },
@@ -1203,13 +1226,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1203
1226
  preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
1204
1227
  // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh).
1205
1228
  // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning;
1206
- // xAI documents high as the upstream default.
1207
- modelReasoningEfforts: { "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.5": ["low", "medium", "high"] },
1229
+ // multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI
1230
+ // documents high as the 4.6 default but no multi-agent default, so do not invent one.
1231
+ modelReasoningEfforts: {
1232
+ "grok-4.6": ["low", "medium", "high", "xhigh"],
1233
+ "grok-4.5": ["low", "medium", "high"],
1234
+ "grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"],
1235
+ },
1208
1236
  modelDefaultReasoningEfforts: { "grok-4.6": "high" },
1209
1237
  modelContextWindows: {
1210
1238
  "grok-4.6": 500_000,
1211
1239
  "grok-4.5": 500_000,
1212
1240
  "grok-4.3": 1_000_000,
1241
+ "grok-4.20-multi-agent-0309": 1_000_000,
1213
1242
  "grok-4.20-0309-reasoning": 1_000_000,
1214
1243
  "grok-4.20-0309-non-reasoning": 1_000_000,
1215
1244
  "grok-build-0.1": 256_000,
@@ -1442,6 +1471,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1442
1471
  // The DeepSeek vision preview id is metadata-only here: the Go roster is
1443
1472
  // discovered live, so it applies the moment the gateway serves the id.
1444
1473
  [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576,
1474
+ // Muse Spark 1.2 Contributor serves a 1,048,576-token (1M) context window over
1475
+ // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28).
1476
+ // Without this declaration the catalog falls back to 128k, capping real usable context.
1477
+ "muse-spark-1.2-contributor": 1_048_576,
1445
1478
  },
1446
1479
  modelInputModalities: {
1447
1480
  "kimi-k3": ["text", "image"],
@@ -1780,6 +1813,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1780
1813
  // context splits a call from its result (#1292); parallel calls remain one
1781
1814
  // reasoning-bearing assistant batch rather than being split per pair (#1477).
1782
1815
  requiresAdjacentResponsesToolResults: true,
1816
+ // DeepSeek exec tool results can be present-but-empty (a script that ran without
1817
+ // calling text(...)); annotate them so routed models do not silently accept an
1818
+ // empty result or re-issue the same call.
1819
+ annotateEmptyToolOutputs: true,
1783
1820
  /* [Decision Log]
1784
1821
  - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
1785
1822
  - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
@@ -2550,13 +2587,23 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2550
2587
  {
2551
2588
  id: "ollama-cloud",
2552
2589
  label: "Ollama Cloud",
2590
+ // The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it
2591
+ // to /api/chat, and live model discovery declares its own /v1/models path against the origin,
2592
+ // so the native transport needs no base-URL edit here or in the free-provider directory.
2553
2593
  baseUrl: "https://ollama.com/v1",
2554
- adapter: "openai-chat",
2594
+ // The native transport must be declared HERE, not in configuration. routedProviderConfig()
2595
+ // overwrites provider.adapter with the registry adapter for every row whose transport
2596
+ // matches, so a config-level adapter is silently discarded.
2597
+ adapter: "ollama-native",
2555
2598
  authKind: "key",
2556
2599
  dashboardUrl: "https://ollama.com/settings/keys",
2557
2600
  // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15.
2558
2601
  models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-pro", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"],
2559
2602
  defaultModel: "glm-5.3",
2603
+ // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have
2604
+ // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep
2605
+ // their existing precedence; these values prevent a failed show from becoming generic.
2606
+ modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 },
2560
2607
  noVisionModels: [
2561
2608
  // glm-5.3-flash is absent on purpose: native VLM
2562
2609
  // (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar.
@@ -2566,6 +2613,19 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2566
2613
  "deepseek-v4-pro", "deepseek-v4-flash",
2567
2614
  "gpt-oss", "qwen3-coder:480b",
2568
2615
  ],
2616
+ // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter
2617
+ // never emits one, so a routed row must not inherit the Codex template's verbosity picker.
2618
+ // Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that
2619
+ // arrive later from live discovery must opt out too (the live-discovery gap closed by #2578).
2620
+ supportsVerbosity: false,
2621
+ // Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models,
2622
+ // so the generic discovery pipeline needs no special-casing. The path is spelled against the
2623
+ // ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery
2624
+ // spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which
2625
+ // 307-redirects to /search and discovery falls back to the configured list.
2626
+ modelDiscovery: {
2627
+ path: "/v1/models",
2628
+ },
2569
2629
  },
2570
2630
  // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
2571
2631
  { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" },
@@ -140,13 +140,21 @@ export function resolveSlugSelection(
140
140
  // selection as provider-qualified made `a/b` resolve against provider "a", so the same
141
141
  // collision reported ambiguous through the dash spelling and unambiguous through the slash
142
142
  // spelling — the exact asymmetry this resolver exists to remove.
143
- const qualified = selection.startsWith(`${provider}/`)
143
+ const matched: string[] = [];
144
+ let exact: string | undefined;
145
+ const ids = [...knownIds];
146
+ // A selection that starts with `<provider>/` is genuinely ambiguous: it reads as the
147
+ // provider-qualified form of `b`, but it is ALSO the native spelling of a self-namespaced
148
+ // id `provider/b`. Stripping the prefix unconditionally erased that second reading, so a
149
+ // published `acme/turbo` became unreachable while a sibling `turbo` silently absorbed the
150
+ // selection. The native id wins when the roster actually publishes it, because only then is
151
+ // the literal spelling known to name a real row.
152
+ const namesNativeId = ids.some(id => id === selection);
153
+ const qualified = !namesNativeId && selection.startsWith(`${provider}/`)
144
154
  ? selection
145
155
  : routedSlug(provider, selection);
146
156
  const selectionKey = slugEquivalenceKey(qualified);
147
- const matched: string[] = [];
148
- let exact: string | undefined;
149
- for (const id of knownIds) {
157
+ for (const id of ids) {
150
158
  if (slugEquivalenceKey(routedSlug(provider, id)) !== selectionKey) continue;
151
159
  matched.push(id);
152
160
  if (id === selection || `${provider}/${id}` === selection) exact = id;
@@ -0,0 +1,108 @@
1
+ import type { OcxProviderConfig, VercelGatewayRouting } from "../types";
2
+ import { sanitizeLogMetadataString } from "../lib/redact";
3
+
4
+ const ROUTING_KEYS = new Set(["order", "only", "sort"]);
5
+ const SORT_VALUES = new Set(["cost", "ttft", "tps"]);
6
+ const MAX_PROVIDER_SLUGS = 64;
7
+
8
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
9
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
10
+ const prototype = Object.getPrototypeOf(value);
11
+ return prototype === Object.prototype || prototype === null;
12
+ }
13
+
14
+ export function isCanonicalVercelGatewayTarget(baseUrl: string): boolean {
15
+ try {
16
+ const url = new URL(baseUrl);
17
+ return url.origin === "https://ai-gateway.vercel.sh"
18
+ && !url.username
19
+ && !url.password
20
+ && !url.search
21
+ && !url.hash
22
+ && url.pathname.replace(/\/+$/, "") === "/v1";
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function routingPreferenceError(value: unknown, field: string): string | null {
29
+ if (!isPlainRecord(value)) return `${field} must be a plain object`;
30
+ const unknown = Object.keys(value).find(key => !ROUTING_KEYS.has(key));
31
+ if (unknown) {
32
+ const sanitized = sanitizeLogMetadataString(unknown);
33
+ return `${field} contains unknown field "${sanitized ?? "unknown"}"`;
34
+ }
35
+
36
+ for (const listField of ["order", "only"] as const) {
37
+ const list = value[listField];
38
+ if (list === undefined) continue;
39
+ if (!Array.isArray(list) || list.length === 0 || list.length > MAX_PROVIDER_SLUGS) {
40
+ return `${field}.${listField} must contain 1-${MAX_PROVIDER_SLUGS} provider slugs`;
41
+ }
42
+ const seen = new Set<string>();
43
+ for (const slug of list) {
44
+ if (typeof slug !== "string" || !slug.trim() || slug !== slug.trim() || slug.length > 128) {
45
+ return `${field}.${listField} must contain nonblank trimmed provider slugs up to 128 characters`;
46
+ }
47
+ if (seen.has(slug)) return `${field}.${listField} must not contain duplicate provider slugs`;
48
+ seen.add(slug);
49
+ }
50
+ }
51
+ if (value.sort !== undefined && (typeof value.sort !== "string" || !SORT_VALUES.has(value.sort))) {
52
+ return `${field}.sort must be "cost", "ttft", or "tps"`;
53
+ }
54
+ if (value.order === undefined && value.only === undefined && value.sort === undefined) {
55
+ return `${field} must define order, only, or sort`;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function vercelGatewayRoutingConfigError(provider: OcxProviderConfig): string | null {
61
+ const hasDefault = provider.vercelGatewayRouting !== undefined;
62
+ const hasModels = provider.modelVercelGatewayRouting !== undefined;
63
+ if (!hasDefault && !hasModels) return null;
64
+ if (provider.adapter !== "openai-chat") {
65
+ return "Vercel AI Gateway routing preferences require the openai-chat adapter";
66
+ }
67
+ if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) {
68
+ return "Vercel AI Gateway routing preferences require the canonical https://ai-gateway.vercel.sh/v1 baseUrl";
69
+ }
70
+ if (hasDefault) {
71
+ const error = routingPreferenceError(provider.vercelGatewayRouting, "vercelGatewayRouting");
72
+ if (error) return error;
73
+ }
74
+ if (hasModels) {
75
+ const routes = provider.modelVercelGatewayRouting;
76
+ if (!isPlainRecord(routes)) return "modelVercelGatewayRouting must be a plain object";
77
+ for (const [modelId, preference] of Object.entries(routes)) {
78
+ if (!modelId.trim() || modelId !== modelId.trim()) {
79
+ return "modelVercelGatewayRouting keys must be nonblank trimmed model ids";
80
+ }
81
+ const sanitizedModel = sanitizeLogMetadataString(modelId) ?? "model";
82
+ const error = routingPreferenceError(preference, `modelVercelGatewayRouting.${sanitizedModel}`);
83
+ if (error) return error;
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+
89
+ export function resolveVercelGatewayRouting(
90
+ provider: OcxProviderConfig,
91
+ modelId: string,
92
+ ): VercelGatewayRouting | undefined {
93
+ if (!isCanonicalVercelGatewayTarget(provider.baseUrl)) return undefined;
94
+ const modelRoutes = provider.modelVercelGatewayRouting;
95
+ return modelRoutes && Object.hasOwn(modelRoutes, modelId)
96
+ ? modelRoutes[modelId]
97
+ : provider.vercelGatewayRouting;
98
+ }
99
+
100
+ export function vercelGatewayProviderPayload(
101
+ preference: VercelGatewayRouting,
102
+ ): Record<string, unknown> {
103
+ return {
104
+ ...(preference.order ? { order: [...preference.order] } : {}),
105
+ ...(preference.only ? { only: [...preference.only] } : {}),
106
+ ...(preference.sort ? { sort: preference.sort } : {}),
107
+ };
108
+ }
package/src/router.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from "./providers/openai-tiers";
34
34
  import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
35
35
  import { resolveModelAlias } from "./providers/default-aliases";
36
+ import { resolveBlockedModelRedirect } from "./lib/shadow-call";
36
37
  import { getStaleCached } from "./codex/model-cache";
37
38
  import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
38
39
  import {
@@ -353,6 +354,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
353
354
  && registryEntry.requiresAdjacentResponsesToolResults !== undefined
354
355
  ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
355
356
  : {}),
357
+ ...(provider.annotateEmptyToolOutputs === undefined
358
+ && registryEntry.annotateEmptyToolOutputs !== undefined
359
+ ? { annotateEmptyToolOutputs: registryEntry.annotateEmptyToolOutputs }
360
+ : {}),
356
361
  ...(provider.fastWire === undefined && registryEntry.fastWire !== undefined
357
362
  ? {
358
363
  fastWire: cloneFastWire(registryEntry.fastWire),
@@ -489,7 +494,7 @@ export function comboRouteDecisionTrace(
489
494
  reason: "combo-pick",
490
495
  candidateIndex: pick.targetIndex,
491
496
  ...(combo
492
- ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
497
+ ? { tieBreak: combo.strategy }
493
498
  : {}),
494
499
  },
495
500
  candidates: combo ? comboRouteCandidates(config, pick, combo) : undefined,
@@ -506,19 +511,23 @@ function isBareOpenAiFamilyModel(modelId: string): boolean {
506
511
  }
507
512
 
508
513
  function routeResult(
514
+ config: OcxConfig | undefined,
509
515
  providerName: string,
510
516
  provider: OcxProviderConfig,
511
517
  modelId: string,
512
518
  routeKind: RouteDecisionKind,
513
519
  routeReason: string,
514
520
  ): RouteResult {
521
+ const redirected = resolveBlockedModelRedirect(config, modelId);
522
+ const effectiveModelId = redirected ?? modelId;
523
+ const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason;
515
524
  const codexAccountMode = providerCodexAccountMode(providerName, provider);
516
525
  return {
517
526
  providerName,
518
527
  provider: routedProviderConfig(providerName, provider),
519
- modelId,
528
+ modelId: effectiveModelId,
520
529
  routeKind,
521
- routeReason,
530
+ routeReason: effectiveRouteReason,
522
531
  ...(codexAccountMode ? { codexAccountMode } : {}),
523
532
  };
524
533
  }
@@ -621,7 +630,7 @@ function routeModelInternal(
621
630
  throw new NoEnabledOpenAiProviderError(nativeModelId);
622
631
  }
623
632
  return {
624
- ...routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
633
+ ...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
625
634
  // Exact account injection uses the pool credential machinery even when the canonical
626
635
  // provider is globally Direct. The fixed id bypasses pool selection entirely.
627
636
  codexAccountMode: "pool",
@@ -666,7 +675,7 @@ function routeModelInternal(
666
675
  // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
667
676
  // remainder, which would send a bare `auto` the upstream cannot resolve.
668
677
  if (known.includes(modelId)) {
669
- return routeResult(provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
678
+ return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
670
679
  }
671
680
  // Codex-facing alias ids (`provider/vendor-model`) decode back to the native
672
681
  // slash id via an exact known-id lookup; raw full-slash selectors keep working.
@@ -676,6 +685,7 @@ function routeModelInternal(
676
685
  ? decoded
677
686
  : resolveModelAlias(config, prov, known, requestedModel) ?? decoded;
678
687
  return routeResult(
688
+ config,
679
689
  provName,
680
690
  prov,
681
691
  nativeModel,
@@ -689,7 +699,7 @@ function routeModelInternal(
689
699
  if (isBareOpenAiFamilyModel(modelId)) {
690
700
  const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
691
701
  if (provider && provider.disabled !== true) {
692
- return routeResult(OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
702
+ return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
693
703
  }
694
704
  throw new NoEnabledOpenAiProviderError(modelId);
695
705
  }
@@ -697,7 +707,7 @@ function routeModelInternal(
697
707
  for (const [provName, prov] of activeProviderEntries(config)) {
698
708
  if (prov.defaultModel === modelId
699
709
  || (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) {
700
- return routeResult(provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
710
+ return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
701
711
  }
702
712
  }
703
713
 
@@ -708,7 +718,7 @@ function routeModelInternal(
708
718
  if (prov.models && Array.isArray(prov.models)) {
709
719
  const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId);
710
720
  if (hit !== undefined) {
711
- return routeResult(provName, prov, hit, "explicit-provider", "configured-model-list");
721
+ return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list");
712
722
  }
713
723
  }
714
724
  }
@@ -728,7 +738,7 @@ function routeModelInternal(
728
738
  }
729
739
  if (aliasMatches[0]) {
730
740
  const match = aliasMatches[0];
731
- return routeResult(match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
741
+ return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
732
742
  }
733
743
 
734
744
  if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) {
@@ -737,7 +747,7 @@ function routeModelInternal(
737
747
  if (hasOwnProvider(config.providers, config.defaultProvider)) {
738
748
  const defaultProv = config.providers[config.defaultProvider];
739
749
  if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
740
- return routeResult(config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
750
+ return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
741
751
  }
742
752
 
743
753
  throw new Error(`No provider configured for model: ${modelId}`);
@@ -763,7 +773,7 @@ export function routeModel(
763
773
  reason: route.routeReason,
764
774
  ...(route.combo ? { candidateIndex: route.combo.targetIndex } : {}),
765
775
  ...(combo
766
- ? { tieBreak: combo.strategy === "round-robin" ? "round-robin" : "failover" }
776
+ ? { tieBreak: combo.strategy }
767
777
  : {}),
768
778
  },
769
779
  candidates: route.routeKind === "combo" && route.combo && combo
@@ -786,7 +796,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
786
796
  );
787
797
  if (matchingProvider) {
788
798
  const [provName, prov] = matchingProvider;
789
- return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern");
799
+ return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern");
790
800
  }
791
801
  // Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by
792
802
  // object insertion order, without checking `models`, `selectedModels`, `disabledModels` or
@@ -1,4 +1,5 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
+ import { extractAccountId } from "../oauth/chatgpt";
2
3
  import { formatErrorResponse } from "../bridge";
3
4
  import {
4
5
  codexAutoStartEnabled,
@@ -27,6 +28,7 @@ import { providerConfigSeed } from "../providers/derive";
27
28
  import type { OcxConfig, OcxProviderConfig } from "../types";
28
29
  import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
29
30
  import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget";
31
+ import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing";
30
32
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
31
33
  import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
32
34
 
@@ -403,6 +405,10 @@ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [
403
405
  { endpoint: "/v1/chat/completions", bearer: "accepted", dedicated: "accepted", xApiKey: "rejected" },
404
406
  { endpoint: "/v1/messages", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
405
407
  { endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
408
+ // #809: least-privilege catalog read for remote Codex clients. Same admission set as
409
+ // /v1/models and for the same reason — it forwards no caller credential upstream — so a
410
+ // remote client no longer needs an admin token just to read the model catalog.
411
+ { endpoint: "/v1/catalog", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" },
406
412
  ];
407
413
 
408
414
  /** Whether `token` is the environment-provided management secret. */
@@ -431,6 +437,14 @@ export function validateForwardAdmissionCredential(headers: Headers, config: Ocx
431
437
  if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
432
438
  }
433
439
 
440
+ /** Whether Authorization carries a caller-owned native Codex credential safe to forward. */
441
+ export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean {
442
+ const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
443
+ const accountId = headers.get("chatgpt-account-id")?.trim()
444
+ || (bearer ? extractAccountId(undefined, bearer) : undefined);
445
+ return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config);
446
+ }
447
+
434
448
  /**
435
449
  * Resolving form of `hasValidApiAuth`: identical header precedence, identical
436
450
  * decision, but it names the admission instead of collapsing it to a boolean.
@@ -568,6 +582,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
568
582
  delete canonicalCandidate.modelContextWindows;
569
583
  // User-owned soft compaction policy; it does not alter the canonical transport seed.
570
584
  delete canonicalCandidate.modelAutoCompactTokenLimits;
585
+ // Same category: annotating empty tool outputs is a user-owned request-shaping preference,
586
+ // not part of the canonical transport seed. Without this the field is accepted by
587
+ // validation and then rejected by the seed comparison, so canonical OpenAI could never
588
+ // set OR clear it — the value was admitted and then refused in the same request.
589
+ delete canonicalCandidate.annotateEmptyToolOutputs;
571
590
  const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed);
572
591
  if (!canonical) {
573
592
  return `provider ${name} must equal the canonical built-in provider seed`;
@@ -636,6 +655,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
636
655
  if (raw.responsesSnapshotRepair !== undefined && typeof raw.responsesSnapshotRepair !== "boolean") {
637
656
  return `provider ${name} responsesSnapshotRepair must be a boolean`;
638
657
  }
658
+ if (raw.xaiResponsesXSearch !== undefined && typeof raw.xaiResponsesXSearch !== "boolean") {
659
+ return `provider ${name} xaiResponsesXSearch must be a boolean`;
660
+ }
639
661
  const defaultMaxOutputError = positiveIntegerConfigError(raw.defaultMaxOutputTokens, "defaultMaxOutputTokens");
640
662
  if (defaultMaxOutputError) return `provider ${name} ${defaultMaxOutputError}`;
641
663
  const maxOutputError = positiveIntegerRecordConfigError(raw.modelMaxOutputTokens, "modelMaxOutputTokens");
@@ -647,6 +669,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
647
669
  if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
648
670
  const openRouterError = openRouterRoutingConfigError(typed);
649
671
  if (openRouterError) return `provider ${name} ${openRouterError}`;
672
+ const vercelError = vercelGatewayRoutingConfigError(typed);
673
+ if (vercelError) return `provider ${name} ${vercelError}`;
650
674
  if (typed.authMode === "local") {
651
675
  // "local" bypasses key-requirement enforcement (api-keys/key-failover treat non-oauth/
652
676
  // forward as key auth; openai-chat skips credential checks for local). Only providers
@@ -725,6 +749,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
725
749
  "modelMaxOutputTokens",
726
750
  "openRouterRouting",
727
751
  "modelOpenRouterRouting",
752
+ "vercelGatewayRouting",
753
+ "modelVercelGatewayRouting",
728
754
  "reasoningEfforts",
729
755
  "modelReasoningEfforts",
730
756
  "reasoningWireFormat",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared serialization for the model catalog, used by both the management route
3
+ * (`GET /api/catalog`) and the least-privilege data-plane route
4
+ * (`GET|HEAD /v1/catalog`, issue #809).
5
+ *
6
+ * The point of the shared module is that the two routes must emit the *same
7
+ * bytes*. A remote Codex client previously had to be handed an admin token just
8
+ * to read the catalog, which is the least-privilege violation #809 is about; the
9
+ * fix is a second route on the data plane, never a widened management boundary.
10
+ * If each route serialized independently they would drift, and the data-plane
11
+ * copy is the one nobody looks at in the dashboard.
12
+ */
13
+ import { createHash } from "node:crypto";
14
+
15
+ /**
16
+ * Upper bound for the REMOTE route only.
17
+ *
18
+ * The first version of this used 32 MiB and applied it to both routes, which was
19
+ * wrong twice over. The repository supports up to 2,000 discovered models, and a
20
+ * 2,000-row catalog serializes to roughly 92 MB — so 32 MiB rejected a valid
21
+ * supported catalog, and applying it to `/api/catalog` turned a working
22
+ * management response into a 507 for those operators.
23
+ *
24
+ * 256 MiB clears the supported bound with room to spare while still refusing a
25
+ * file that could only be corrupt or hostile. The management route is not
26
+ * subject to it at all: it is a local dashboard read whose behavior predates
27
+ * this module and must not change.
28
+ */
29
+ export const MAX_REMOTE_CATALOG_BYTES = 256 * 1024 * 1024;
30
+
31
+ export interface SerializedCatalog {
32
+ /** Serialized catalog JSON, or null when no catalog could be materialized. */
33
+ body: string | null;
34
+ /** Strong ETag over `body`, present only when `body` is. */
35
+ etag?: string;
36
+ /** Byte length of `body`, present only when `body` is. */
37
+ bytes?: number;
38
+ }
39
+
40
+ export function catalogEtag(body: string): string {
41
+ return `"${createHash("sha256").update(body).digest("hex")}"`;
42
+ }
43
+
44
+ /**
45
+ * Read and serialize the persisted catalog once.
46
+ *
47
+ * Returns `{ body: null }` for every unreadable case — absent file, unreadable
48
+ * file, malformed JSON — because `readCatalog` already collapses those into
49
+ * `null` and the routes render them identically as a 404. Distinguishing them
50
+ * here would invite one route to leak a filesystem path in an error message.
51
+ *
52
+ * Deliberately does NOT apply a size ceiling: a size policy belongs to the route
53
+ * that serves the bytes, not to the shared serializer both planes depend on.
54
+ */
55
+ export async function serializePersistedCatalog(): Promise<SerializedCatalog> {
56
+ const { readCatalog, readCodexCatalogPath } = await import("../codex/catalog");
57
+ const catalog = readCatalog(readCodexCatalogPath());
58
+ if (!catalog) return { body: null };
59
+ const body = JSON.stringify(catalog);
60
+ const bytes = Buffer.byteLength(body, "utf8");
61
+ return { body, etag: catalogEtag(body), bytes };
62
+ }
63
+
64
+ /**
65
+ * The authoritative Codex version for a catalog response, or undefined.
66
+ *
67
+ * Never fabricated: when no runtime is persisted the header is omitted rather
68
+ * than guessed, so a client cannot mistake "unknown" for a specific version.
69
+ */
70
+ export async function persistedCodexVersion(): Promise<string | undefined> {
71
+ const { loadPersistedCodexRuntime } = await import("../codex/runtime");
72
+ return loadPersistedCodexRuntime()?.selectedVersion ?? undefined;
73
+ }
@@ -21,6 +21,7 @@ import { isModelTextOnly } from "../vision";
21
21
  import {
22
22
  applyUpstreamRecoveryInit,
23
23
  fetchWithResetRetry,
24
+ fetchWithTransientRetry,
24
25
  prepareSameTarget429Wait,
25
26
  type UpstreamSendRecovery,
26
27
  } from "../lib/upstream-retry";
@@ -33,6 +34,7 @@ import {
33
34
  rateLimitRetryDelayMs,
34
35
  rateLimitRetryPolicyFor,
35
36
  rotateProviderTransportOn429,
37
+ transientRetryPolicyFor,
36
38
  } from "../providers/key-failover";
37
39
  import { fastPolicyForModel } from "../providers/service-tier";
38
40
  import type { RouteResult } from "../router";
@@ -204,7 +206,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
204
206
 
205
207
  const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise<Response> => {
206
208
  try {
207
- return await fetchWithResetRetry(
209
+ // #2643: opted-in key-auth openai-chat providers retry pre-stream transient statuses on
210
+ // the native chat lane too; everyone else keeps reset-only semantics.
211
+ const transientPolicy = transientRetryPolicyFor(activeProvider);
212
+ const fetchWithPolicy = transientPolicy ? fetchWithTransientRetry : fetchWithResetRetry;
213
+ return await fetchWithPolicy(
208
214
  (transportRecovery?: UpstreamSendRecovery) => {
209
215
  noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
210
216
  return fetchWithHeaderTimeout(
@@ -223,7 +229,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
223
229
  }),
224
230
  );
225
231
  },
226
- { abortSignal: upstream.signal, label: safeHostLabel(request.url) },
232
+ {
233
+ abortSignal: upstream.signal,
234
+ label: safeHostLabel(request.url),
235
+ ...(transientPolicy ? { attempts: transientPolicy.attempts } : {}),
236
+ },
227
237
  );
228
238
  } finally {
229
239
  request.releaseBodyObservation?.();
@@ -127,7 +127,10 @@ export function serveGuiFile(
127
127
  const ext = extname(filePath);
128
128
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
129
129
  if (ext === ".html") return htmlResponse(filePath, session);
130
- return new Response(Bun.file(filePath), {
130
+ // Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced
131
+ // after Bun frames the response but before the stream finishes, its Content-Length can
132
+ // describe the old file while the body comes from the new one (#2792).
133
+ return new Response(readFileSync(filePath), {
131
134
  headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
132
135
  });
133
136
  }