@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.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 (166) hide show
  1. package/gui/dist/assets/index-CWXut3rG.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
package/src/router.ts CHANGED
@@ -349,6 +349,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
349
349
  const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels);
350
350
  const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels);
351
351
  const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
352
+ const noJsonSchemaModels = mergeStringArray(registryEntry.noJsonSchemaModels, provider.noJsonSchemaModels);
352
353
  const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
353
354
  const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
354
355
  const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels);
@@ -376,6 +377,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
376
377
  ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
377
378
  ? { responsesPath: registryEntry.responsesPath }
378
379
  : {}),
380
+ ...(provider.chatCompletionsPath === undefined && registryEntry.chatCompletionsPath !== undefined
381
+ ? { chatCompletionsPath: registryEntry.chatCompletionsPath }
382
+ : {}),
379
383
  ...(provider.requiresAdjacentResponsesToolResults === undefined
380
384
  && registryEntry.requiresAdjacentResponsesToolResults !== undefined
381
385
  ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
@@ -473,6 +477,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
473
477
  ...(noTemperatureModels ? { noTemperatureModels } : {}),
474
478
  ...(noTopPModels ? { noTopPModels } : {}),
475
479
  ...(noPenaltyModels ? { noPenaltyModels } : {}),
480
+ ...(noJsonSchemaModels ? { noJsonSchemaModels } : {}),
476
481
  ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
477
482
  ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
478
483
  ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}),
@@ -681,7 +686,7 @@ function routeModelInternal(
681
686
  }
682
687
  }
683
688
 
684
- // 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4-pro").
689
+ // 0. Explicit "<provider>/<model>" namespace (e.g. "opencode-go/deepseek-v4.1-flash").
685
690
  // Only triggers when the prefix matches a CONFIGURED provider, so genuine
686
691
  // slash-containing model ids (e.g. "anthropic/claude-...") fall through when
687
692
  // no such provider exists.
@@ -14,6 +14,8 @@ export function upstreamProtocolForAdapter(adapter: string): string {
14
14
  case "openai-chat":
15
15
  case "command-code":
16
16
  case "cursor":
17
+ case "devin-cli":
18
+ case "devin":
17
19
  case "azure":
18
20
  case "azure-openai":
19
21
  case "kiro":
@@ -147,6 +149,7 @@ export function resolveProductionBehaviorValues(
147
149
  "wire.adapter": behaviorRow("provider_config", adapter),
148
150
  "wire.upstreamProtocol": behaviorRow("provider_config", upstreamProtocol),
149
151
  "wire.responsesPath": behaviorRow("provider_config", effective.responsesPath ?? null),
152
+ "wire.chatCompletionsPath": behaviorRow("provider_config", effective.chatCompletionsPath ?? null),
150
153
  "wire.commandCodeVersion": behaviorRow("provider_config", effective.commandCodeVersion ?? null),
151
154
  "wire.modelSuffixMode": behaviorRow(
152
155
  "provider_config",
@@ -48,6 +48,10 @@ export function resolveWireProtocolOverride(
48
48
  }
49
49
 
50
50
  /** Build the provider adapter for a resolved provider config. */
51
- export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") {
52
- return createRegisteredAdapter(providerConfig, { cacheRetention });
51
+ export function resolveAdapter(
52
+ providerConfig: OcxProviderConfig,
53
+ cacheRetention?: "none" | "short" | "long",
54
+ providerId?: string,
55
+ ) {
56
+ return createRegisteredAdapter(providerConfig, { cacheRetention, ...(providerId ? { providerId } : {}) });
53
57
  }
@@ -1,4 +1,4 @@
1
- import { timingSafeEqual } from "node:crypto";
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { initialModelSelection } from "../providers/initial-model-selection";
3
3
  import { extractAccountId } from "../oauth/chatgpt";
4
4
  import { formatErrorResponse } from "../bridge";
@@ -353,9 +353,29 @@ function secretEquals(actual: string, expected: string | undefined): boolean {
353
353
  */
354
354
  export type DataPlaneAdmissionSource = "loopback" | "dedicated" | "bearer" | "x-api-key";
355
355
 
356
+ /**
357
+ * Process-local, salted identity of the admission secret that was actually matched.
358
+ *
359
+ * Context-relay ownership is partitioned by this value, so two operators holding different
360
+ * keys cannot reach each other's sessions even when both resolve to the same upstream
361
+ * workspace. `source` is deliberately excluded: the same key arriving as a bearer or in the
362
+ * dedicated header is one principal. Rotating or replacing a secret mints a new principal and
363
+ * drops continuity, which is the safe direction — a reused key id or a replaced environment
364
+ * secret must not inherit the previous holder's sessions. Loopback admission carries no caller
365
+ * identity and mints nothing, so the relay refuses it rather than treating every local process
366
+ * as one user.
367
+ */
368
+ const CONTEXT_PRINCIPAL_SALT = randomBytes(32);
369
+
370
+ function mintContextPrincipal(kind: string, keyId: string, credential: string): string {
371
+ return createHmac("sha256", CONTEXT_PRINCIPAL_SALT)
372
+ .update(kind).update("\0").update(keyId).update("\0").update(credential)
373
+ .digest("hex");
374
+ }
375
+
356
376
  export type DataPlaneAdmission =
357
- | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource }
358
- | { kind: "environment"; source: DataPlaneAdmissionSource }
377
+ | { kind: "configured"; keyId: string; source: DataPlaneAdmissionSource; contextPrincipalId?: string }
378
+ | { kind: "environment"; source: DataPlaneAdmissionSource; contextPrincipalId?: string }
359
379
  | { kind: "loopback"; source: "loopback" };
360
380
 
361
381
  /**
@@ -374,17 +394,52 @@ export function resolveDataPlaneAdmissionSecret(
374
394
  ): DataPlaneAdmission | null {
375
395
  const actual = token.trim();
376
396
  if (!actual) return null;
377
- if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment", source };
397
+ if (secretEquals(actual, configuredApiAuthToken(config))) {
398
+ return { kind: "environment", source, contextPrincipalId: mintContextPrincipal("environment", "", actual) };
399
+ }
378
400
  for (const k of config.apiKeys ?? []) {
379
- if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id, source };
401
+ if (secretEquals(actual, k.key)) {
402
+ return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, actual) };
403
+ }
380
404
  const pending = k.pendingRotation;
381
405
  if (pending && Date.parse(pending.expiresAt) > Date.now() && secretEquals(actual, pending.key)) {
382
- return { kind: "configured", keyId: k.id, source };
406
+ return { kind: "configured", keyId: k.id, source, contextPrincipalId: mintContextPrincipal("configured", k.id, pending.key) };
383
407
  }
384
408
  }
385
409
  return null;
386
410
  }
387
411
 
412
+ /** The principal an authenticated admission belongs to, or undefined for loopback. */
413
+ export function contextPrincipalIdOf(admission: DataPlaneAdmission | undefined): string | undefined {
414
+ return admission && "contextPrincipalId" in admission ? admission.contextPrincipalId : undefined;
415
+ }
416
+
417
+ /**
418
+ * The caller principal for a context-relay request, which is a stricter question than admission.
419
+ *
420
+ * The default bind is loopback, where admission deliberately never reads a token, so an admitted
421
+ * request carries no caller identity. History ownership needs one, so the relay asks separately:
422
+ * a caller that presents a real opencodex API key gets that key’s principal even on loopback,
423
+ * and a caller that presents none gets nothing and is refused. This adds identity where the caller
424
+ * volunteered it; it does not admit anyone who was not already admitted, and it does not change
425
+ * which credential goes upstream.
426
+ */
427
+ export function resolveContextPrincipal(req: Request, config: OcxConfig, admission: DataPlaneAdmission | undefined): string | undefined {
428
+ const named = contextPrincipalIdOf(admission);
429
+ if (named) return named;
430
+ if (admission?.kind !== "loopback") return undefined;
431
+ const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
432
+ const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
433
+ const apiKey = req.headers.get("x-api-key")?.trim();
434
+ for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) {
435
+ if (!token) continue;
436
+ const resolved = resolveDataPlaneAdmissionSecret(token, config, source);
437
+ const principal = contextPrincipalIdOf(resolved ?? undefined);
438
+ if (principal) return principal;
439
+ }
440
+ return undefined;
441
+ }
442
+
388
443
  /** Whether `token` is a data-plane admission secret. */
389
444
  export function isDataPlaneAdmissionSecret(token: string, config: OcxConfig): boolean {
390
445
  return resolveDataPlaneAdmissionSecret(token, config) !== null;
@@ -712,6 +767,11 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
712
767
  "noStructuredOutputModels",
713
768
  );
714
769
  if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
770
+ const jsonSchemaOptOutError = nonBlankStringArrayConfigError(
771
+ raw.noJsonSchemaModels,
772
+ "noJsonSchemaModels",
773
+ );
774
+ if (jsonSchemaOptOutError) return `provider ${name} ${jsonSchemaOptOutError}`;
715
775
  const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels");
716
776
  if (retainModelsError) return `provider ${name} ${retainModelsError}`;
717
777
  const toolReasoningOptOutError = nonBlankStringArrayConfigError(
@@ -794,6 +854,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
794
854
  fastWire: "editor",
795
855
  baseUrl: "editor",
796
856
  responsesPath: "editor",
857
+ chatCompletionsPath: "editor",
797
858
  commandCodeVersion: "editor",
798
859
  statelessResponses: "editor",
799
860
  requiresAdjacentResponsesToolResults: "editor",
@@ -812,6 +873,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
812
873
  apiKey: "redacted",
813
874
  apiKeyTransport: "editor",
814
875
  apiKeyPool: "redacted",
876
+ // Ordering preference only; it names no key material, so an editor may read and set it.
877
+ apiKeyPoolStrategy: "editor",
815
878
  apiKeySelectionRevision: "runtime",
816
879
  _apiKeyAttempt: "runtime",
817
880
  defaultModel: "editor",
@@ -855,6 +918,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
855
918
  supportsOpenAiWebSearchToolFields: "editor",
856
919
  xaiResponsesXSearch: "editor",
857
920
  xaiResponsesDefaultVersion: "runtime",
921
+ zaiResponsesDefaultVersion: "runtime",
858
922
  supportsResponsesCustomTools: "editor",
859
923
  responsesSnapshotRepair: "editor",
860
924
  webSearchBridge: "editor",
@@ -866,6 +930,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
866
930
  noTopPModels: "editor",
867
931
  noPenaltyModels: "editor",
868
932
  noStructuredOutputModels: "editor",
933
+ noJsonSchemaModels: "editor",
869
934
  omitReasoningEffortWithToolsModels: "editor",
870
935
  parallelToolCalls: "editor",
871
936
  pinParallelToolCallsFalse: "editor",
@@ -260,9 +260,9 @@ async function handleChatCompletionsWithBudget(
260
260
  const value = req.headers.get(name);
261
261
  if (value) headers.set(name, value);
262
262
  }
263
- // A noncanonical caller-auth route can use stored main auth only through a sidecar snapshot.
264
- // Later shadow/thread rewrites strip primary credentials at the actual Responses boundary.
265
- if (!callerAuthorizationRoute || (settledRoute && !isCanonicalOpenAiForwardProvider(settledRoute.provider))) {
263
+ // Existing primary enrichment stays on non-caller-auth routes. Caller-auth routes defer
264
+ // optional stored sidecar auth until the final helper plan actually needs it.
265
+ if (!callerAuthorizationRoute) {
266
266
  // This enrichment is optional for routed/non-main providers. If native main
267
267
  // is fenced, omit it and let auth-context reject only a final physical-main
268
268
  // selection while healthy pool/provider routes continue.
@@ -317,6 +317,8 @@ async function handleChatCompletionsWithBudget(
317
317
  };
318
318
  const upstream = await handleResponses(internalReq, config, logCtx, {
319
319
  openAiSidecarAuth,
320
+ allowStoredOpenAiSidecarAuth: !!(callerAuthorizationRoute && settledRoute
321
+ && !isCanonicalOpenAiForwardProvider(settledRoute.provider)),
320
322
  nativeCallerAuth,
321
323
  callerDirectAuth,
322
324
  ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
@@ -33,6 +33,7 @@ import {
33
33
  } from "../lib/translator-budget";
34
34
  import {
35
35
  hasKeyPoolFailover,
36
+ selectProactiveApiKeyTransport,
36
37
  rateLimitRetryDelayMs,
37
38
  rateLimitRetryPolicyFor,
38
39
  rotateProviderTransportOn429,
@@ -235,6 +236,14 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
235
236
  unregisterTurn(upstream);
236
237
  };
237
238
  const connectMs = config.connectTimeoutMs ?? 200_000;
239
+ // Native chat is its own entry path -- chat-completions.ts routes here directly and never
240
+ // through the Responses core -- so the pre-dispatch key preference is applied again here
241
+ // rather than inherited. Assigned before the adapter binds below, for the same reason it is
242
+ // assigned before the transport pin in core.ts.
243
+ // Transport variant: the bare picker answers with the persisted row, which for a built-in
244
+ // provider carries no adapter id or base URL until routedProviderConfig backfills it.
245
+ const proactiveKeyProvider = selectProactiveApiKeyTransport(config, route.providerName, route.provider);
246
+ if (proactiveKeyProvider) route.provider = proactiveKeyProvider;
238
247
  let activeProvider: OcxProviderConfig = route.provider;
239
248
  let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
240
249
  let activeRequest: AdapterRequest;
@@ -30,7 +30,6 @@ import {
30
30
  import { clearableDeadline, idleDeadline } from "../lib/abort";
31
31
  import { estimateTokens } from "../lib/token-estimate";
32
32
  import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
33
- import { registryEntryForProviderDestination } from "../providers/registry";
34
33
  import { evidenceFromBody } from "../routing/request-evidence";
35
34
  import { resolveWireProtocolOverride } from "./adapter-resolve";
36
35
  import type { OcxConfig } from "../types";
@@ -38,6 +37,7 @@ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-dec
38
37
  import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
39
38
  import {
40
39
  conversationIdFromClaudeMetadata,
40
+ getOrAllocateRequestSessionLane,
41
41
  linkRequestSessionLane,
42
42
  normalizeLogConversationId,
43
43
  sessionLaneIdFromRequest,
@@ -637,6 +637,12 @@ export async function handleClaudeMessages(
637
637
  }
638
638
  }
639
639
 
640
+ /**
641
+ * Translate a Claude Messages request, route it through the Responses pipeline,
642
+ * and translate the reply back. Runs under a translator budget owned by the
643
+ * caller; Go session affinity is derived here and handed to the final Go
644
+ * transport out of band rather than through replay headers.
645
+ */
640
646
  async function handleClaudeMessagesWithBudget(
641
647
  req: Request,
642
648
  config: OcxConfig,
@@ -791,19 +797,13 @@ async function handleClaudeMessagesWithBudget(
791
797
  // Native ChatGPT passthrough (openai-responses forward) accepts only Codex-shaped
792
798
  // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens",
793
799
  // verified live 2026-07-11). Strip them for that route; routed providers keep them.
794
- let nativeRoute = false;
795
- let opencodeGoRoute = false;
796
800
  try {
797
801
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
798
- // Match the fixed key-auth destination before per-model wire overrides, including
799
- // renamed Go providers without treating custom or lookalike URLs as Go.
800
- opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go";
801
802
  // Settle the wire once so the sampling decision below reads the effective
802
803
  // adapter rather than the provider-wide default (#404).
803
804
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
804
805
  logCtx.routeDecision = route.routeDecision;
805
806
  if (route.provider.adapter === "openai-responses") {
806
- nativeRoute = true;
807
807
  delete internalBody.max_output_tokens;
808
808
  delete internalBody.temperature;
809
809
  delete internalBody.top_p;
@@ -867,30 +867,23 @@ async function handleClaudeMessagesWithBudget(
867
867
  };
868
868
  }
869
869
  }
870
- if (opencodeGoRoute) {
871
- const session = req.headers.get("x-opencode-session");
872
- if (session) headers.set("x-opencode-session", session);
873
- }
874
- const hasExplicitGoSession = opencodeGoRoute
875
- && (sessionLaneIdFromRequest(headers) !== undefined
876
- || normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined);
877
- const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession
870
+ // Carry Go identity out of band: a combo's preflight target may differ from its
871
+ // actual dispatch/fallback target. Never add Go-only identity to replay headers.
872
+ const claudeNativeSessionId = cacheKeySource === "metadata"
873
+ && typeof internalBody.prompt_cache_key === "string"
878
874
  && isRec(anthropicBody)
879
- && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined;
880
- // Go can also use the Responses adapter; its eligibility gate must win on both wires.
881
- if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) {
882
- // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex
883
- // clients always send their session uuid; devlog 090 follow-up: body-level
884
- // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends
885
- // the header, so synthesize a stable per-session uuid from the same cache key.
886
- // Routed Go requests need this lane too for their x-opencode-session affinity —
887
- // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback
888
- // key is shared across Desktop conversations, and a shared session_id's backend
889
- // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there.
890
- if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") {
891
- headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key));
892
- }
893
- }
875
+ && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined
876
+ ? uuidFromHex(internalBody.prompt_cache_key)
877
+ : undefined;
878
+ const metadataGoLane = normalizeLogConversationId(claudeNativeSessionId);
879
+ // Without any valid conversation identity, fall back to the request-scoped lane
880
+ // allocated on the admitted client request (#4172): stable across retries and
881
+ // route reconstruction, distinct per request, and never derived from a shared
882
+ // system-prompt cache key or from a later synthesized native session_id header.
883
+ const claudeGoSessionLane = sessionLaneIdFromRequest(headers)
884
+ ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))
885
+ ?? metadataGoLane
886
+ ?? getOrAllocateRequestSessionLane(req);
894
887
  let internalReq: Request;
895
888
  try {
896
889
  // The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch.
@@ -934,6 +927,8 @@ async function handleClaudeMessagesWithBudget(
934
927
  // Without this the replay would look native and a Responses-scoped wire default
935
928
  // would fire, disagreeing with the pre-flight decision above.
936
929
  inboundWire: "anthropic",
930
+ claudeGoAffinity: { sessionLane: claudeGoSessionLane },
931
+ claudeNativeSessionId,
937
932
  stripClaudeMainAuthForNoncanonicalForward: true,
938
933
  ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}),
939
934
  // Claude's internal stored-main enrichment is not an original caller credential.
@@ -0,0 +1,207 @@
1
+ /** Native history/notes JSON relay. No interpretation of encrypted tool arguments or retries. */
2
+ import { formatErrorResponse } from "../bridge";
3
+ import {
4
+ CodexAccountCooldownError, CodexAuthContextError, CodexMainProfileDrainingError, CodexDirectAuthenticationError,
5
+ CodexPoolAuthenticationError, CodexThreadAffinityExpiredError, CodexMainSubstitutionUnavailableError,
6
+ codexMainProfileDrainingResponse, cooldownErrorResponse,
7
+ materializeCodexUpstreamAuth, isCodexAuthContextUsable, resolveCodexAuthContext, releaseCodexAuthContextProbeLease,
8
+ } from "../codex/auth-context";
9
+ import { getContextSessionOwner, contextSessionOwnerMatches } from "../codex/context-owner";
10
+ import { contextEndpoint, contextRelayActivated } from "../codex/context-compat";
11
+ import { formatCodexProviderForLog } from "../codex/routing";
12
+ import { listOpenAiForwardSidecarCandidates } from "../providers/openai-sidecar";
13
+ import { clearableDeadline, type ClearableDeadline } from "../lib/abort";
14
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
15
+ import type { AdmissionLease } from "../lib/admission";
16
+ import type { OcxConfig } from "../types";
17
+ import { resolveContextPrincipal, ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
18
+ import { readBoundedJsonRequestBody } from "./request-decompress";
19
+ import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
20
+ import { codexAccountSelectionForTurn } from "./lifecycle";
21
+ import type { RequestLogContext } from "./request-log";
22
+
23
+ const PROTOCOL_HEADERS = ["x-openai-encrypted-tool-arguments", "x-openai-tool-output-truncation-policy"];
24
+ const RESPONSE_HEADERS = ["content-type", "retry-after", "x-request-id", "openai-processing-ms", ...PROTOCOL_HEADERS];
25
+ const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
26
+ const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
27
+ const RELAY_DEADLINE_MS = 35_000;
28
+
29
+ export function contextSelectionHeaders(headers: Headers, sessionId: string): Headers {
30
+ const result = new Headers(headers);
31
+ // Codex history tools carry root session_id in JSON, unlike Responses' HTTP headers.
32
+ // Root model requests use (session-id=root, thread-id=root); don't fabricate a parent key.
33
+ if (!result.has("x-codex-parent-thread-id") && !result.has("session-id") && !result.has("thread-id")) {
34
+ result.set("session-id", sessionId);
35
+ result.set("thread-id", sessionId);
36
+ }
37
+ return result;
38
+ }
39
+
40
+ /**
41
+ * One deadline covers the whole operation, starting before the body is read.
42
+ *
43
+ * Reading the body and selecting a credential are both waits a caller controls, and this route
44
+ * holds an admitted turn slot for their duration. A deadline that started at dispatch would let
45
+ * an unfinished body or a stalled refresh hold that slot with no bound at all.
46
+ */
47
+ export async function handleContextHistory(
48
+ req: Request, config: OcxConfig, logCtx: RequestLogContext,
49
+ endpoint: string, turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission,
50
+ revalidateAdmission?: () => DataPlaneAdmission | null,
51
+ ): Promise<Response> {
52
+ const deadline = clearableDeadline(RELAY_DEADLINE_MS, req.signal);
53
+ try {
54
+ return await relayContextHistory(req, config, logCtx, endpoint, deadline, turnAdmissionLease,
55
+ admission, revalidateAdmission);
56
+ } finally {
57
+ deadline.clear();
58
+ }
59
+ }
60
+
61
+ async function relayContextHistory(
62
+ req: Request, config: OcxConfig, logCtx: RequestLogContext,
63
+ endpoint: string, deadline: ClearableDeadline,
64
+ turnAdmissionLease?: AdmissionLease, admission?: DataPlaneAdmission,
65
+ revalidateAdmission?: () => DataPlaneAdmission | null,
66
+ ): Promise<Response> {
67
+ // The feature is opt-in, and the opt-in has to hold here rather than only where the injected
68
+ // base URL is rewritten: a caller that can reach the data plane can POST these paths directly.
69
+ // While it is off the endpoints do not exist, which is also what a disabled route should look
70
+ // like from outside.
71
+ if (!contextEndpoint("/v1/" + endpoint) || req.method !== "POST" || !contextRelayActivated()) {
72
+ return formatErrorResponse(404, "not_found", "Unknown context endpoint");
73
+ }
74
+ // Only a trusted listener admission authorizes replacing a proxy bearer with Codex auth.
75
+ const substituteMainCredential = admission?.source === "bearer";
76
+ try { if (!substituteMainCredential) validateForwardAdmissionCredential(req.headers, config); }
77
+ catch (err) {
78
+ if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
79
+ throw err;
80
+ }
81
+ // A workspace is not a person and a local socket is not a caller. Ownership is keyed by the
82
+ // admission secret that was actually matched, so an admission that carries no principal —
83
+ // loopback — cannot own or reach a session.
84
+ const principalId = resolveContextPrincipal(req, config, admission);
85
+ if (!principalId) {
86
+ return formatErrorResponse(403, "context_principal_required",
87
+ "Context history requires an opencodex API key on the request; admission alone carries no caller identity");
88
+ }
89
+ let body: unknown;
90
+ try { body = await readBoundedJsonRequestBody(req, MAX_REQUEST_BYTES, undefined, { signal: deadline.signal }); }
91
+ catch (err) {
92
+ const cancelled = cancellationResponse(req, deadline);
93
+ if (cancelled) return cancelled;
94
+ return decodeRequestErrorResponse(err, "context_history");
95
+ }
96
+ const sessionId = (body as {context?: {session_id?: unknown}} | null)?.context?.session_id;
97
+ if (typeof sessionId !== "string" || !/^[A-Za-z0-9._:-]{1,512}$/.test(sessionId)) {
98
+ return formatErrorResponse(400, "invalid_request_error", "context.session_id must be a bounded nonempty string");
99
+ }
100
+ const candidate = listOpenAiForwardSidecarCandidates(config)[0];
101
+ if (!candidate) return formatErrorResponse(400, "invalid_request_error", "History and notes require the native ChatGPT forward provider");
102
+ const rootHeader = req.headers.get("x-codex-parent-thread-id")?.trim() || req.headers.get("session-id")?.trim();
103
+ if (rootHeader && rootHeader !== sessionId) {
104
+ return formatErrorResponse(409, "context_account_unavailable", "Context root does not match this request");
105
+ }
106
+ const owner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl);
107
+ if (!owner || owner.ambiguous || (owner.kind === "caller" && substituteMainCredential)) {
108
+ return formatErrorResponse(409, "context_account_unavailable",
109
+ "Context account ownership is unavailable; start a new session and preserve needed state before resetting context");
110
+ }
111
+ let authContext: Awaited<ReturnType<typeof resolveCodexAuthContext>>;
112
+ const headers = new Headers(candidate.provider.headers);
113
+ try {
114
+ authContext = await resolveCodexAuthContext(contextSelectionHeaders(req.headers, sessionId), config, owner.kind === "stored" ? "pool" : "direct", {
115
+ // The same deadline bounds credential selection and refresh waits.
116
+ signal: deadline.signal,
117
+ // Resolve the proven physical owner as an explicit account. Context operations
118
+ // never create affinity, rotate on quota, or inspect file-main for a caller owner.
119
+ modelId: "context_history",
120
+ ...(owner.kind === "stored" ? { accountId: owner.accountId } : { requestScopedMainCredential: true }),
121
+ admission,
122
+ substituteMainCredentialForDirect: substituteMainCredential,
123
+ beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
124
+ });
125
+ if (authContext.kind !== "main" && authContext.probeLeaseId) {
126
+ // History traffic must not occupy or settle the model's quota-recovery probe.
127
+ releaseCodexAuthContextProbeLease(authContext);
128
+ return formatErrorResponse(503, "upstream_error", "Model quota recovery is pending; retry context operation later");
129
+ }
130
+ if (!isCodexAuthContextUsable(authContext, config)) throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
131
+ logCtx.provider = formatCodexProviderForLog(candidate.providerName, codexLogAccountId(authContext), config);
132
+ // Materialization rechecks the current account policy after async selection.
133
+ // Synthetic lane IDs are local selection metadata, never upstream headers.
134
+ for (const [key, value] of materializeCodexUpstreamAuth(req.headers, authContext, {
135
+ config, modelId: "context_history", admission, substituteMainCredential,
136
+ })) {
137
+ headers.set(key, value);
138
+ }
139
+ // Check the assembled outbound headers, including configured provider headers.
140
+ validateForwardAdmissionCredential(headers, config);
141
+ // Recheck actual wire identity after async selection/materialization. A replaced
142
+ // account slot or login must not receive another physical account's history.
143
+ const currentOwner = getContextSessionOwner(principalId, sessionId, candidate.provider.baseUrl);
144
+ if (!currentOwner || currentOwner.kind !== owner.kind
145
+ || !contextSessionOwnerMatches(owner, headers) || !contextSessionOwnerMatches(currentOwner, headers)) {
146
+ return formatErrorResponse(409, "context_account_unavailable", "Context account identity changed; start a new session");
147
+ }
148
+ } catch (err) {
149
+ const cancelled = cancellationResponse(req, deadline);
150
+ if (cancelled) return cancelled;
151
+ if (err instanceof CodexAccountCooldownError) return cooldownErrorResponse(err);
152
+ if (err instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse();
153
+ if (err instanceof CodexThreadAffinityExpiredError) return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
154
+ if (err instanceof CodexAuthContextError || err instanceof CodexPoolAuthenticationError || err instanceof CodexDirectAuthenticationError
155
+ || err instanceof CodexMainSubstitutionUnavailableError || err instanceof ForwardAdmissionCredentialError) {
156
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account is unavailable or needs reauthentication");
157
+ }
158
+ throw err;
159
+ }
160
+ headers.set("content-type", "application/json");
161
+ for (const key of PROTOCOL_HEADERS) {
162
+ const value = req.headers.get(key); if (value !== null) headers.set(key, value);
163
+ }
164
+ // Nothing is dispatched after cancellation; a notes write is not replayable.
165
+ const cancelledBeforeDispatch = cancellationResponse(req, deadline);
166
+ if (cancelledBeforeDispatch) return cancelledBeforeDispatch;
167
+ // Body reading and credential selection are both waits, and a key can be revoked, rotated or
168
+ // replaced during them. Re-resolve admission against the receiving listener policy and require
169
+ // the same principal, so a withdrawn key cannot dispatch on a snapshot taken minutes earlier.
170
+ if (revalidateAdmission && resolveContextPrincipal(req, config, revalidateAdmission() ?? undefined) !== principalId) {
171
+ return formatErrorResponse(401, "authentication_error",
172
+ "opencodex API key changed during this request; retry with current credentials");
173
+ }
174
+ // The operator may disable the feature while body or credential IO is pending.
175
+ if (!contextRelayActivated()) return formatErrorResponse(404, "not_found", "Unknown context endpoint");
176
+ let response: Response | undefined;
177
+ try {
178
+ response = await fetch(`${candidate.provider.baseUrl}/${endpoint}`, {
179
+ method: "POST", headers, body: JSON.stringify(body), signal: deadline.signal, redirect: "manual",
180
+ });
181
+ const result = await readBoundedResponseBytes(response, {maxBytes: MAX_RESPONSE_BYTES, signal: deadline.signal});
182
+ if (result.oversized) return formatErrorResponse(502, "upstream_error", "Context response exceeded 16 MiB");
183
+ const outputHeaders = new Headers();
184
+ for (const key of RESPONSE_HEADERS) {
185
+ const value = response.headers.get(key); if (value !== null) outputHeaders.set(key, value);
186
+ }
187
+ // A context 403 is not evidence that the model credential is invalid. Don't mutate pool
188
+ // health/quota or retry writes; preserve the real upstream result for the caller.
189
+ return new Response([204,205,304].includes(response.status) ? null : result.bytes, {status:response.status, headers:outputHeaders});
190
+ } catch {
191
+ return cancellationResponse(req, deadline)
192
+ ?? formatErrorResponse(502, "upstream_error", "Context upstream connection failed");
193
+ } finally {
194
+ if (response?.body && !response.body.locked) void response.body.cancel().catch(() => undefined);
195
+ }
196
+ }
197
+
198
+ /** Client cancellation and deadline expiry are different outcomes and must not share a status. */
199
+ function cancellationResponse(req: Request, deadline: ClearableDeadline): Response | undefined {
200
+ if (req.signal.aborted) {
201
+ return formatErrorResponse(499, "client_closed_request", "Context request canceled by client");
202
+ }
203
+ if (deadline.didExpire()) {
204
+ return formatErrorResponse(504, "upstream_error", "Context operation exceeded its deadline");
205
+ }
206
+ return undefined;
207
+ }
@@ -28,6 +28,7 @@ import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounde
28
28
  import { sidecarEnter } from "../lib/sidecar-tracker";
29
29
  import type { OcxConfig } from "../types";
30
30
  import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
31
+ import { selectProactiveApiKeyTransport } from "../providers/key-failover";
31
32
  import { getProviderRegistryEntry } from "../providers/registry";
32
33
  import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
33
34
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
@@ -698,7 +699,33 @@ export async function handleImages(
698
699
  // Do not hide a broken/expired pool behind separately billed API-key image generation.
699
700
  return forwardAuthError;
700
701
  } else if (candidates.keyed) {
701
- const { provider, apiKey, providerName } = candidates.keyed;
702
+ const { providerName } = candidates.keyed;
703
+ // The keyed image path builds its own URL and Authorization header and never enters
704
+ // handleResponses, so the pre-dispatch key pick happens here.
705
+ //
706
+ // Two things about the placement. It stays INSIDE this branch because higher up it would
707
+ // also run for requests ChatGPT forward goes on to serve, spending a rotation on a path
708
+ // that never used the key. And the header is rebuilt from the returned route rather than
709
+ // from candidates.keyed.apiKey, which is a snapshot resolved earlier: reusing it would
710
+ // send the OLD key while the picker had already persisted the new one.
711
+ //
712
+ // Transport variant: this branch reads `provider.baseUrl` and `provider.headers` to build
713
+ // the URL and the request, and it comes back with the credential already resolved.
714
+ const warmKeyProvider = selectProactiveApiKeyTransport(config, providerName, candidates.keyed.provider);
715
+ const provider = warmKeyProvider ?? candidates.keyed.provider;
716
+ // No fall back to the earlier snapshot once a pick has happened. The picker COMMITS its
717
+ // choice before returning, so if the chosen reference will not resolve -- revoked keychain
718
+ // entry, unset env var -- sending the previous key would authenticate a non-idempotent
719
+ // POST with a credential the config no longer considers active, and the previous key is
720
+ // the one that was cooling. Fail loudly instead.
721
+ if (warmKeyProvider && !warmKeyProvider.apiKey?.trim()) {
722
+ return formatErrorResponse(
723
+ 500,
724
+ "configuration_error",
725
+ `image generation selected an API key for "${providerName}" that cannot be resolved`,
726
+ );
727
+ }
728
+ const apiKey = warmKeyProvider?.apiKey ?? candidates.keyed.apiKey;
702
729
  if (provider.headers) Object.assign(headers, provider.headers);
703
730
  headers["authorization"] = `Bearer ${apiKey}`;
704
731
  logCtx.provider = providerName;