@bitkyc08/opencodex 2.91.0 → 2.92.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 (148) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
  3. package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
  4. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  5. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  8. package/package.json +4 -3
  9. package/src/adapters/google-vertex-endpoint.ts +9 -1
  10. package/src/adapters/google-vertex-key-project.ts +223 -0
  11. package/src/adapters/google.ts +13 -3
  12. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  13. package/src/adapters/openai-chat.ts +2 -2
  14. package/src/adapters/openai-responses/request-strips.ts +60 -0
  15. package/src/adapters/registry.ts +5 -0
  16. package/src/adapters/workbuddy-schema.ts +61 -0
  17. package/src/adapters/workbuddy.ts +68 -0
  18. package/src/bridge/response-json.ts +1 -1
  19. package/src/bridge/sse.ts +1 -1
  20. package/src/chat/outbound.ts +11 -4
  21. package/src/cli/capabilities-agents-routing.ts +3 -3
  22. package/src/cli/codex-cli-update.ts +8 -0
  23. package/src/cli/combo-result.ts +6 -0
  24. package/src/cli/combo-stats.ts +3 -0
  25. package/src/cli/combo.ts +66 -42
  26. package/src/cli/launcher-context.ts +49 -2
  27. package/src/cli/link.ts +45 -32
  28. package/src/cli/provider.ts +2 -1
  29. package/src/cli/usage-compare.ts +68 -12
  30. package/src/client/link-ingress.ts +1 -1
  31. package/src/client/link-readiness.ts +97 -0
  32. package/src/client/link-relay-transport.ts +79 -11
  33. package/src/client/link-relay.ts +74 -15
  34. package/src/client/link-tunnel.ts +45 -18
  35. package/src/client/machine-listener.ts +20 -2
  36. package/src/client/runtime.ts +20 -2
  37. package/src/codex/account-store.ts +17 -3
  38. package/src/codex/app-server-restart-service.ts +6 -5
  39. package/src/codex/catalog/effort.ts +9 -2
  40. package/src/codex/catalog/gather-capture.ts +4 -2
  41. package/src/codex/catalog/provider-models.ts +14 -8
  42. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  43. package/src/codex/cli-install-provenance.ts +418 -0
  44. package/src/codex/cli-installation-targets.ts +5 -1
  45. package/src/codex/cli-update-plan.ts +43 -16
  46. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  47. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  48. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  49. package/src/codex/pool-refresh-backoff.ts +3 -1
  50. package/src/codex/user-identity.ts +35 -6
  51. package/src/codex/windows-current-sid.ts +162 -0
  52. package/src/combos/jev-quota-activation.ts +5 -1
  53. package/src/combos/jev-quota-route.ts +1 -1
  54. package/src/combos/jev-quota.ts +9 -1
  55. package/src/combos/jev-session-pin-config.ts +51 -0
  56. package/src/combos/jev.ts +6 -0
  57. package/src/combos/types.ts +19 -0
  58. package/src/config/provider-validation.ts +7 -0
  59. package/src/config/schema/leaf-validators.ts +1 -0
  60. package/src/generated/compatibility-version.json +194 -122
  61. package/src/lib/decision-quota-sink.ts +10 -0
  62. package/src/lib/translator-budget.ts +4 -4
  63. package/src/lib/upstream-retry.ts +4 -1
  64. package/src/link/diagnostics.ts +193 -0
  65. package/src/link/relay-auth.ts +6 -0
  66. package/src/link/status-projection.ts +39 -0
  67. package/src/link/supervisor.ts +45 -12
  68. package/src/link/tunnel-history.ts +109 -0
  69. package/src/link/tunnel-state.ts +80 -17
  70. package/src/messaging/remote-files.ts +24 -1
  71. package/src/oauth/generic-account-failover.ts +91 -7
  72. package/src/oauth/index.ts +15 -0
  73. package/src/oauth/model-discovery-auth.ts +5 -0
  74. package/src/oauth/store.ts +4 -1
  75. package/src/oauth/token-guardian.ts +7 -0
  76. package/src/oauth/types.ts +9 -0
  77. package/src/oauth/workbuddy.ts +139 -0
  78. package/src/providers/antigravity-quota-identity.ts +37 -0
  79. package/src/providers/base-url-choices.ts +8 -5
  80. package/src/providers/derive.ts +23 -3
  81. package/src/providers/model-discovery.ts +2 -0
  82. package/src/providers/model-rename-fields.ts +1 -0
  83. package/src/providers/quota/account-cache.ts +3 -2
  84. package/src/providers/quota/antigravity.ts +25 -14
  85. package/src/providers/quota/report-cache.ts +13 -4
  86. package/src/providers/quota/vendor-probes-key.ts +60 -0
  87. package/src/providers/quota/workbuddy.ts +83 -0
  88. package/src/providers/quota-decision-antigravity.ts +64 -0
  89. package/src/providers/quota-decision-publication.ts +11 -0
  90. package/src/providers/quota-decision-snapshot.ts +21 -4
  91. package/src/providers/quota-types.ts +2 -0
  92. package/src/providers/quota.ts +3 -1
  93. package/src/providers/registry/entries-core.ts +9 -0
  94. package/src/providers/registry/entries-extended.ts +20 -7
  95. package/src/providers/registry/model-ids.ts +3 -0
  96. package/src/providers/registry/model-seeds.ts +27 -28
  97. package/src/providers/registry/types.ts +19 -1
  98. package/src/providers/resolved-model-policy.ts +4 -4
  99. package/src/providers/routed-config.ts +15 -0
  100. package/src/providers/stale-model-roster-migration.ts +83 -15
  101. package/src/providers/workbuddy-credit-balance.ts +81 -0
  102. package/src/providers/workbuddy-models.ts +37 -0
  103. package/src/providers/workbuddy.ts +60 -0
  104. package/src/responses/reasoning-replay-cache.ts +18 -14
  105. package/src/router.ts +2 -0
  106. package/src/routing/analytics.ts +2 -3
  107. package/src/routing/fallback-policy.ts +3 -3
  108. package/src/server/auth-cors.ts +1 -0
  109. package/src/server/chat-completions.ts +14 -1
  110. package/src/server/chat-native-reasoning.ts +10 -3
  111. package/src/server/chat-native.ts +13 -2
  112. package/src/server/claude-messages.ts +3 -0
  113. package/src/server/index/link-relay-sessions.ts +9 -5
  114. package/src/server/index/optional-listeners.ts +4 -0
  115. package/src/server/local-management-read-client.ts +3 -1
  116. package/src/server/management/combo-routes.ts +10 -4
  117. package/src/server/management/link-routes.ts +5 -2
  118. package/src/server/management/provider-routes.ts +1 -1
  119. package/src/server/port-reclaim.ts +51 -9
  120. package/src/server/request-log-conversation.ts +49 -0
  121. package/src/server/request-log.ts +2 -0
  122. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  123. package/src/server/responses/compact.ts +10 -2
  124. package/src/server/responses/core-combo.ts +53 -31
  125. package/src/server/responses/core-lifetime.ts +2 -2
  126. package/src/server/responses/core-normalize.ts +6 -3
  127. package/src/server/responses/core-opaque-recovery.ts +1 -0
  128. package/src/server/responses/core-options.ts +5 -0
  129. package/src/server/responses/core-replay.ts +2 -1
  130. package/src/server/responses/jev-combo-choice.ts +39 -0
  131. package/src/server/responses/jev-session-pin.ts +203 -0
  132. package/src/server/responses/passthrough-delivery.ts +4 -0
  133. package/src/server/responses/request-prepare.ts +10 -5
  134. package/src/server/responses/request-transport.ts +25 -6
  135. package/src/server/responses/run-turn-execution.ts +2 -4
  136. package/src/server/responses/sidecar-execution.ts +2 -4
  137. package/src/server/responses-phase-inference.ts +267 -0
  138. package/src/server/sse-payload-rewrite.ts +14 -1
  139. package/src/service/definition.ts +20 -8
  140. package/src/types/config.ts +6 -0
  141. package/src/types/provider.ts +9 -1
  142. package/src/types/request.ts +1 -0
  143. package/src/types/wire.ts +1 -0
  144. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  145. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  146. package/src/usage/jev-stats.ts +54 -14
  147. package/gui/dist/assets/index-DGXS51sp.js +0 -94
  148. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
@@ -10,13 +10,18 @@
10
10
  * This rewrites one thing: a saved `models` list that is still byte-for-byte the roster
11
11
  * this file names as `from`, on a provider that still carries the registry adapter, and
12
12
  * only while the registry currently seeds `to`. A list the user changed does not match and
13
- * is left alone. For each added id it also fills a per-model context window and default
14
- * effort, but only inside a container the row already has: enrichment fills those records
15
- * all-or-nothing, so creating one here would hide the rest of the registry seed.
13
+ * is left alone. For each added id it also fills a per-model context window, output limit
14
+ * and default effort, but only inside a container the row already has: enrichment fills
15
+ * those records all-or-nothing, so creating one here would hide the rest of the registry seed.
16
16
  * Same shape and restraint as `stale-context-window-migration`.
17
17
  */
18
- import { PROVIDER_REGISTRY } from "./registry";
18
+ import { ALIBABA_INTL_TOKEN_PLAN_BASE_URL } from "./base-url-choices";
19
+ import { PROVIDER_REGISTRY, normalizedProviderEndpoint } from "./registry";
19
20
  import {
21
+ ALIBABA_TOKEN_PLAN_MODELS,
22
+ ALIBABA_INTL_TOKEN_PLAN_MODELS,
23
+ ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
24
+ ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
20
25
  MINIMAX_M31_DEFAULT_REASONING_EFFORT,
21
26
  MINIMAX_M31_FLASH_PREVIEW,
22
27
  MINIMAX_MODELS,
@@ -33,7 +38,18 @@ export interface StaleModelRoster {
33
38
  to: readonly string[];
34
39
  /** Per-model seed values for the added ids, written only into containers that exist. */
35
40
  contextWindows?: Readonly<Record<string, number>>;
41
+ /**
42
+ * Custom catalog rows read the saved map directly, so an added id missing from a
43
+ * partial map would publish without the registry output limit.
44
+ */
45
+ maxOutputTokens?: Readonly<Record<string, number>>;
36
46
  defaultReasoningEfforts?: Readonly<Record<string, string>>;
47
+ /**
48
+ * For a provider whose saved URL may point elsewhere (pay-as-you-go or Custom), the
49
+ * destinations whose roster `to` describes. A row on any other URL keeps its list; a row
50
+ * with no saved URL uses the registry default and is refreshed.
51
+ */
52
+ destinationBaseUrls?: readonly string[];
37
53
  }
38
54
 
39
55
  export interface StaleModelRosterProjection {
@@ -42,17 +58,45 @@ export interface StaleModelRosterProjection {
42
58
  warnings: string[];
43
59
  }
44
60
 
61
+ const ALIBABA_TOKEN_PLAN_MODELS_BEFORE_1017 = [
62
+ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash",
63
+ "deepseek-v4-pro", "deepseek-v4-flash-0731", "deepseek-v4.1-flash", "glm-5.2", "glm-5.3",
64
+ ];
65
+ const ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017 = [
66
+ "qwen3.8-max", "qwen3.8-flash", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash",
67
+ "deepseek-v4-pro", "deepseek-v4-pro-0813", "deepseek-v4-flash", "deepseek-v4-flash-0731", "deepseek-v4.1-flash", "deepseek-v3.2",
68
+ "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5",
69
+ "glm-5.2", "glm-5.3", "glm-5.1", "glm-5",
70
+ "MiniMax-M2.5",
71
+ ];
72
+
45
73
  /**
46
74
  * MiniMax-M3.1-Flash-Preview (2026-09-30). MiniMax's /v1/models roster does not list the
47
75
  * preview, so live discovery cannot add it; the catalog keeps it only when it is configured.
48
76
  */
49
- export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = ["minimax", "minimax-cn"].map(provider => ({
50
- provider,
51
- from: MINIMAX_MODELS_BEFORE_M31,
52
- to: MINIMAX_MODELS,
53
- contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! },
54
- defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT },
55
- }));
77
+ export const STALE_MODEL_ROSTERS: readonly StaleModelRoster[] = [
78
+ ...["minimax", "minimax-cn"].map(provider => ({
79
+ provider,
80
+ from: MINIMAX_MODELS_BEFORE_M31,
81
+ to: MINIMAX_MODELS,
82
+ contextWindows: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_MODEL_CONTEXT_WINDOWS[MINIMAX_M31_FLASH_PREVIEW]! },
83
+ defaultReasoningEfforts: { [MINIMAX_M31_FLASH_PREVIEW]: MINIMAX_M31_DEFAULT_REASONING_EFFORT },
84
+ })),
85
+ ...[
86
+ { provider: "alibaba-token-plan", from: ALIBABA_TOKEN_PLAN_MODELS_BEFORE_1017, to: ALIBABA_TOKEN_PLAN_MODELS },
87
+ // International honors pay-as-you-go and Custom URLs, which may still serve the retired ids.
88
+ // Beijing needs no guard: the router pins it to the registry Token Plan host.
89
+ {
90
+ provider: "alibaba-token-plan-intl", from: ALIBABA_INTL_TOKEN_PLAN_MODELS_BEFORE_1017, to: ALIBABA_INTL_TOKEN_PLAN_MODELS,
91
+ destinationBaseUrls: [ALIBABA_INTL_TOKEN_PLAN_BASE_URL],
92
+ },
93
+ ].map(entry => ({
94
+ ...entry,
95
+ contextWindows: ALIBABA_TOKEN_PLAN_CONTEXT_WINDOWS,
96
+ maxOutputTokens: ALIBABA_TOKEN_PLAN_MAX_OUTPUT_TOKENS,
97
+ defaultReasoningEfforts: { "deepseek-v4.1-flash-1017": "high" },
98
+ })),
99
+ ];
56
100
 
57
101
  function sameList(value: unknown, expected: readonly string[]): boolean {
58
102
  return Array.isArray(value)
@@ -65,12 +109,24 @@ function registrySeeds(provider: string, adapter: unknown, roster: readonly stri
65
109
  return entry !== undefined && entry.adapter === adapter && sameList(entry.models, roster);
66
110
  }
67
111
 
68
- function fillExisting<T>(container: Record<string, T> | undefined, values: Readonly<Record<string, T>> | undefined, ids: readonly string[]): void {
69
- if (!container || !values) return;
112
+ function servesRoster(baseUrl: unknown, destinations: readonly string[] | undefined): boolean {
113
+ if (!destinations || baseUrl === undefined || baseUrl === "") return true;
114
+ if (typeof baseUrl !== "string") return false;
115
+ const saved = normalizedProviderEndpoint(baseUrl);
116
+ return destinations.some(url => normalizedProviderEndpoint(url) === saved);
117
+ }
118
+
119
+ function fillExisting<T>(container: Record<string, T> | undefined, values: Readonly<Record<string, T>> | undefined, ids: readonly string[]): boolean {
120
+ if (!container || !values) return false;
121
+ let filled = false;
70
122
  for (const id of ids) {
71
123
  const value = values[id];
72
- if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) container[id] = value;
124
+ if (value !== undefined && !Object.prototype.hasOwnProperty.call(container, id)) {
125
+ container[id] = value;
126
+ filled = true;
127
+ }
73
128
  }
129
+ return filled;
74
130
  }
75
131
 
76
132
  /** Pure projection. The caller decides whether to persist. */
@@ -85,10 +141,22 @@ export function projectStaleModelRosters(
85
141
  const prov = config.providers?.[entry.provider];
86
142
  if (!prov) continue;
87
143
  if (!registrySeeds(entry.provider, prov.adapter, entry.to)) continue;
88
- if (!sameList(prov.models, entry.from)) continue;
144
+ if (!servesRoster(prov.baseUrl, entry.destinationBaseUrls)) continue;
89
145
  const added = entry.to.filter(id => !entry.from.includes(id));
146
+ if (sameList(prov.models, entry.to)) {
147
+ // A row refreshed before output limits joined the refresh still has a partial map.
148
+ // Only that record is revisited: the earlier pass already filled windows and efforts,
149
+ // so refilling them could restore a key the operator has since removed.
150
+ if (fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added)) {
151
+ changed = true;
152
+ warnings.push(`filled output limits for ${added.join(", ")} on the saved "${entry.provider}" row.`);
153
+ }
154
+ continue;
155
+ }
156
+ if (!sameList(prov.models, entry.from)) continue;
90
157
  prov.models = [...entry.to];
91
158
  fillExisting(prov.modelContextWindows, entry.contextWindows, added);
159
+ fillExisting(prov.modelMaxOutputTokens, entry.maxOutputTokens, added);
92
160
  fillExisting(prov.modelDefaultReasoningEfforts, entry.defaultReasoningEfforts, added);
93
161
  changed = true;
94
162
  warnings.push(
@@ -0,0 +1,81 @@
1
+ export interface WorkBuddyCreditAmount {
2
+ total: number;
3
+ used: number;
4
+ remaining: number;
5
+ frozen: number;
6
+ }
7
+
8
+ export const WORKBUDDY_CREDIT_KINDS = ["plan_base", "plan_bonus", "addon", "benefit", "other"] as const;
9
+ export type WorkBuddyCreditKind = typeof WORKBUDDY_CREDIT_KINDS[number];
10
+
11
+ /** Display-only credits; they are neither USD nor provider-wide model eligibility. */
12
+ export interface WorkBuddyCreditBalance extends WorkBuddyCreditAmount {
13
+ groups: Array<WorkBuddyCreditAmount & { kind: WorkBuddyCreditKind }>;
14
+ expiring?: { amount: number; at: number };
15
+ }
16
+
17
+ export function workBuddyRecord(value: unknown): Record<string, unknown> {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
19
+ }
20
+
21
+ export function workBuddyCapacity(value: unknown): number | undefined {
22
+ if (typeof value !== "number" && !(typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value))) return undefined;
23
+ const number = Number(value);
24
+ return Number.isFinite(number) && number >= 0 && number <= Number.MAX_SAFE_INTEGER ? number : undefined;
25
+ }
26
+
27
+ function amount(value: unknown): WorkBuddyCreditAmount | undefined {
28
+ const row = workBuddyRecord(value);
29
+ const total = workBuddyCapacity(row.total), used = workBuddyCapacity(row.used);
30
+ const remaining = workBuddyCapacity(row.remaining), frozen = workBuddyCapacity(row.frozen);
31
+ if (total === undefined || used === undefined || remaining === undefined || frozen === undefined) return undefined;
32
+ return { total, used, remaining, frozen };
33
+ }
34
+
35
+ /** Narrow wire/cache input before rendering, without admitting account or package identifiers. */
36
+ export function normalizeWorkBuddyCredits(value: unknown): WorkBuddyCreditBalance | undefined {
37
+ const row = workBuddyRecord(value), balance = amount(value);
38
+ if (!balance || !Array.isArray(row.groups) || row.groups.length > WORKBUDDY_CREDIT_KINDS.length) return undefined;
39
+ const groups: WorkBuddyCreditBalance["groups"] = [];
40
+ const seen = new Set<string>();
41
+ for (const raw of row.groups) {
42
+ const group = workBuddyRecord(raw), quantities = amount(group);
43
+ const kind = WORKBUDDY_CREDIT_KINDS.find(kind => kind === group.kind);
44
+ if (!quantities || !kind || seen.has(kind)) return undefined;
45
+ seen.add(kind); groups.push({ kind, ...quantities });
46
+ }
47
+ const expiring = workBuddyRecord(row.expiring), quantity = workBuddyCapacity(expiring.amount);
48
+ const at = typeof expiring.at === "number" && expiring.at > 0 && Number.isFinite(new Date(expiring.at).getTime()) ? expiring.at : undefined;
49
+ return { ...balance, groups, ...(quantity !== undefined && quantity > 0 && at !== undefined ? { expiring: { amount: quantity, at } } : {}) };
50
+ }
51
+
52
+ function packageAmount(row: Record<string, unknown>): WorkBuddyCreditAmount | undefined {
53
+ const total = workBuddyCapacity(row.CycleTotalCapacity), used = workBuddyCapacity(row.CycleUsedCapacity);
54
+ const frozen = row.CycleFrozenCapacity === undefined ? 0 : workBuddyCapacity(row.CycleFrozenCapacity);
55
+ if (total === undefined || used === undefined || frozen === undefined) return undefined;
56
+ const remaining = row.CycleRemainCapacity === undefined ? Math.max(0, total - used - frozen) : workBuddyCapacity(row.CycleRemainCapacity);
57
+ return remaining === undefined ? undefined : { total, used, remaining, frozen };
58
+ }
59
+
60
+ export function parseWorkBuddyCredits(value: unknown): WorkBuddyCreditBalance | undefined {
61
+ const summary = workBuddyRecord(value);
62
+ if (!Array.isArray(summary.Packages) || summary.Packages.length > 512) return undefined;
63
+ const balance: WorkBuddyCreditBalance = { total: 0, used: 0, remaining: 0, frozen: 0, groups: [] };
64
+ for (const raw of summary.Packages) {
65
+ const quantities = packageAmount(workBuddyRecord(raw));
66
+ if (!quantities) return undefined;
67
+ for (const key of ["total", "used", "remaining", "frozen"] as const) balance[key] += quantities[key];
68
+ }
69
+ // CreditGroups is a breakdown of Packages, not another balance to add to it.
70
+ if (Array.isArray(summary.CreditGroups) && summary.CreditGroups.length <= 16) {
71
+ for (const raw of summary.CreditGroups) {
72
+ const row = workBuddyRecord(raw), quantities = packageAmount(row);
73
+ if (!quantities) continue;
74
+ const kind = WORKBUDDY_CREDIT_KINDS.find(kind => kind === row.Kind) ?? "other";
75
+ const existing = balance.groups.find(group => group.kind === kind);
76
+ if (existing) for (const key of ["total", "used", "remaining", "frozen"] as const) existing[key] += quantities[key];
77
+ else balance.groups.push({ kind, ...quantities });
78
+ }
79
+ }
80
+ return normalizeWorkBuddyCredits(balance);
81
+ }
@@ -0,0 +1,37 @@
1
+ import { isValidModelDiscoveryModelId } from "./model-discovery-limits";
2
+ import type { ProviderModelItemsResult, ProviderModelsApiItem } from "./model-discovery";
3
+
4
+ function record(value: unknown): Record<string, unknown> {
5
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
6
+ }
7
+
8
+ /** The product catalog contains non-chat models too; only the CLI agent's roster is routable. */
9
+ export function extractWorkBuddyModels(value: unknown, limit: number): ProviderModelItemsResult {
10
+ const envelope = record(value), data = record(envelope.data);
11
+ if ((envelope.code !== 0 && envelope.code !== "0") || !Array.isArray(data.agents) || !Array.isArray(data.models)) return { ok: false, reason: "invalid_shape" };
12
+ if (data.models.length > limit || data.agents.length > limit) return { ok: false, reason: "too_many_models" };
13
+ const agent = data.agents.map(record).find(a => a.name === "cli");
14
+ if (!Array.isArray(agent?.models)) return { ok: false, reason: "invalid_shape" };
15
+ if (agent.models.length > limit) return { ok: false, reason: "too_many_models" };
16
+ const definitions = new Map(data.models.map(raw => { const row = record(raw); return [row.id, row] as const; }));
17
+ const items: ProviderModelsApiItem[] = [], seen = new Set<string>();
18
+ for (const id of agent.models) {
19
+ if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
20
+ if (seen.has(id)) continue;
21
+ seen.add(id);
22
+ const row = definitions.get(id) ?? {}, reasoning = record(row.reasoning);
23
+ const rawEfforts = Array.isArray(reasoning.supportedEfforts) ? reasoning.supportedEfforts
24
+ : row.supportsReasoning === true ? ["low", "high", "max"] : [];
25
+ const efforts = rawEfforts.filter(e => typeof e === "string" && ["low", "medium", "high", "xhigh", "max"].includes(e));
26
+ if (efforts.length && row.onlyReasoning !== true && reasoning.canDisableThinking !== false) efforts.unshift("none");
27
+ const positive = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v > 0 ? v : undefined;
28
+ items.push({
29
+ id, ...(typeof row.name === "string" && row.name.length <= 256 ? { name: row.name } : {}),
30
+ context_length: positive(row.maxInputTokens), max_input_tokens: positive(row.maxInputTokens),
31
+ max_output_tokens: positive(row.maxOutputTokens), reasoning_efforts: efforts,
32
+ // WorkBuddy AI filters images from GLM requests despite advertising supportsImages.
33
+ input_modalities: row.supportsImages === true && !id.startsWith("glm-") ? ["text", "image"] : ["text"],
34
+ });
35
+ }
36
+ return { ok: true, items, rawCount: agent.models.length };
37
+ }
@@ -0,0 +1,60 @@
1
+ import type { WorkBuddyOAuthMetadata } from "../oauth/types";
2
+ import { createHash } from "node:crypto";
3
+
4
+ export const WORKBUDDY_ORIGIN = "https://www.workbuddy.ai";
5
+ export const WORKBUDDY_BASE_URL = `${WORKBUDDY_ORIGIN}/v2`;
6
+ export const WORKBUDDY_CONFIG_URL = `${WORKBUDDY_ORIGIN}/v3/config`;
7
+ export const WORKBUDDY_USER_AGENT = "WorkBuddy/5.5.6";
8
+
9
+ export function workBuddyCatalogIdentity(access: string, metadata: WorkBuddyOAuthMetadata | undefined): string {
10
+ return createHash("sha256").update(JSON.stringify([access, metadata])).digest("hex");
11
+ }
12
+
13
+ export function workBuddyHeaderValue(value: unknown, max = 1024): value is string {
14
+ return typeof value === "string" && value.length > 0 && value.length <= max && /^[\x21-\x7e]+$/.test(value);
15
+ }
16
+
17
+ export function normalizeWorkBuddyMetadata(value: unknown): WorkBuddyOAuthMetadata | undefined {
18
+ if (!value || typeof value !== "object") return undefined;
19
+ const v = value as Partial<WorkBuddyOAuthMetadata>;
20
+ if (!workBuddyHeaderValue(v.uid) || !workBuddyHeaderValue(v.domain)) return undefined;
21
+ if (v.enterpriseId !== undefined && !workBuddyHeaderValue(v.enterpriseId)) return undefined;
22
+ return {
23
+ uid: v.uid, domain: v.domain,
24
+ ...(v.enterpriseId ? { enterpriseId: v.enterpriseId } : {}),
25
+ ...(typeof v.refreshExpiresAt === "number" && Number.isFinite(v.refreshExpiresAt) && v.refreshExpiresAt > 0
26
+ ? { refreshExpiresAt: v.refreshExpiresAt } : {}),
27
+ };
28
+ }
29
+
30
+ export function assertWorkBuddyDestination(baseUrl: string): void {
31
+ const u = new URL(baseUrl);
32
+ if (u.origin !== WORKBUDDY_ORIGIN || u.username || u.password || u.search || u.hash
33
+ || !["", "/v2", "/v2/chat/completions"].includes(u.pathname.replace(/\/+$/, ""))) {
34
+ throw new Error("WorkBuddy AI credentials require https://www.workbuddy.ai/v2");
35
+ }
36
+ }
37
+
38
+ export function workBuddyHeaders(access: string | undefined, metadata?: WorkBuddyOAuthMetadata): Record<string, string> {
39
+ const headers: Record<string, string> = {
40
+ "User-Agent": WORKBUDDY_USER_AGENT,
41
+ "X-Product": "SaaS", "X-IDE-Type": "WorkBuddy", "X-IDE-Name": "WorkBuddy",
42
+ "X-IDE-Version": "5.5.6", "X-Requested-With": "XMLHttpRequest",
43
+ "X-Agent-Intent": "craft", "X-Agent-Purpose": "conversation", "X-Agent-Type": "main",
44
+ };
45
+ if (access) {
46
+ if (!workBuddyHeaderValue(access, 32768)) throw new Error("Invalid WorkBuddy AI access token");
47
+ headers.Authorization = `Bearer ${access}`;
48
+ }
49
+ if (metadata) {
50
+ const valid = normalizeWorkBuddyMetadata(metadata);
51
+ if (!valid) throw new Error("WorkBuddy AI account identity unavailable; sign in again");
52
+ headers["X-User-Id"] = valid.uid;
53
+ headers["X-Domain"] = valid.domain;
54
+ if (valid.enterpriseId) {
55
+ headers["X-Enterprise-Id"] = valid.enterpriseId;
56
+ headers["X-Tenant-Id"] = valid.enterpriseId;
57
+ }
58
+ }
59
+ return headers;
60
+ }
@@ -143,8 +143,9 @@ function tupleForServingIdentity(
143
143
 
144
144
  function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
145
145
  const identity = tupleForIdentity(scope?.current);
146
- if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined;
146
+ if (!nonEmpty(callId) || !nonEmpty(scope?.clientPrincipalId) || !nonEmpty(scope.clientThreadId) || !identity) return undefined;
147
147
  return JSON.stringify([
148
+ scope.clientPrincipalId,
148
149
  scope.clientThreadId,
149
150
  ...identity,
150
151
  callId,
@@ -166,18 +167,21 @@ function sweepExpiredServingIdentities(at: number): void {
166
167
 
167
168
  function servingIdentityFor(
168
169
  scope: OcxReasoningReplayScopeRef | undefined,
169
- ): { threadId: string; identity: string } | undefined {
170
+ ): { key: string; identity: string } | undefined {
170
171
  const threadId = scope?.clientThreadId;
171
172
  const identityTuple = tupleForServingIdentity(scope?.current);
172
173
  if (!nonEmpty(threadId) || !identityTuple) return undefined;
173
- return { threadId, identity: JSON.stringify(identityTuple) };
174
+ // Preserve physical safeguards for keyless callers in a separate namespace. A caller's
175
+ // thread name cannot select an authenticated principal's serving or rejection history.
176
+ const key = JSON.stringify([nonEmpty(scope?.clientPrincipalId) ? scope.clientPrincipalId : null, threadId]);
177
+ return { key, identity: JSON.stringify(identityTuple) };
174
178
  }
175
179
 
176
180
  function opaqueBlobRejectionKeyFor(
177
181
  scope: OcxReasoningReplayScopeRef | undefined,
178
182
  ): string | undefined {
179
183
  const current = servingIdentityFor(scope);
180
- return current ? JSON.stringify([current.threadId, current.identity]) : undefined;
184
+ return current ? JSON.stringify([current.key, current.identity]) : undefined;
181
185
  }
182
186
 
183
187
  function deleteOpaqueBlobRejection(key: string): void {
@@ -224,7 +228,7 @@ export function reasoningReplayServingIdentityChange(
224
228
  if (!current) return undefined;
225
229
  const at = now();
226
230
  sweepExpiredServingIdentities(at);
227
- const previous = servingIdentities.get(current.threadId);
231
+ const previous = servingIdentities.get(current.key);
228
232
  if (previous === undefined || previous.identity === current.identity) return undefined;
229
233
  const before = JSON.parse(previous.identity) as ReasoningReplayIdentityTuple;
230
234
  const after = JSON.parse(current.identity) as ReasoningReplayIdentityTuple;
@@ -254,7 +258,7 @@ export function reasoningReplayItemStoreChanged(
254
258
  if (!current) return false;
255
259
  const at = now();
256
260
  sweepExpiredServingIdentities(at);
257
- const previous = servingIdentities.get(current.threadId);
261
+ const previous = servingIdentities.get(current.key);
258
262
  if (previous === undefined || previous.identity === current.identity) return false;
259
263
  const storeOwner = (identity: string): string => {
260
264
  const [, destination, , , credential] = JSON.parse(identity) as ReasoningReplayIdentityTuple;
@@ -271,16 +275,16 @@ export function commitReasoningReplayServingIdentity(
271
275
  if (!current) return;
272
276
  const at = now();
273
277
  sweepExpiredServingIdentities(at);
274
- const previous = servingIdentities.get(current.threadId);
275
- const { threadId, identity } = current;
276
- const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8");
278
+ const previous = servingIdentities.get(current.key);
279
+ const { key, identity } = current;
280
+ const bytes = Buffer.byteLength(JSON.stringify([key, identity]), "utf8");
277
281
  if (bytes > MAX_TOTAL_BYTES) {
278
- deleteServingIdentity(threadId);
282
+ deleteServingIdentity(key);
279
283
  return;
280
284
  }
281
285
 
282
- if (previous) deleteServingIdentity(threadId);
283
- servingIdentities.set(threadId, { identity, bytes, at });
286
+ if (previous) deleteServingIdentity(key);
287
+ servingIdentities.set(key, { identity, bytes, at });
284
288
  servingIdentityTotalBytes += bytes;
285
289
  while (
286
290
  (servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES)
@@ -517,13 +521,13 @@ export function reasoningReplayKeyCredentialIdentity(
517
521
  return reasoningReplayCredentialIdentity("key", apiKey, provider.headers);
518
522
  }
519
523
 
520
- /** Replace only the holder snapshot so parsed-request copies observe rotations. */
524
+ /** Bind physical identity independently of the principal required for plaintext cache access. */
521
525
  export function bindReasoningReplayScope(
522
526
  scope: OcxReasoningReplayScopeRef | undefined,
523
527
  identity: OcxReasoningReplayIdentity | undefined,
524
528
  ): void {
525
529
  if (!scope) return;
526
- if (!identity || !keyFor("binding-check", { ...scope, current: identity })) {
530
+ if (!identity || !nonEmpty(scope.clientThreadId) || !tupleForIdentity(identity)) {
527
531
  delete scope.current;
528
532
  return;
529
533
  }
package/src/router.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
34
34
  import { effectiveProviderAliasDecision, resolveModelAlias } from "./providers/default-aliases";
35
35
  import { resolveBlockedModelRedirect } from "./lib/shadow-call";
36
+ import { workBuddyCatalogIdentity } from "./providers/workbuddy";
36
37
  import { getRoutingCached } from "./codex/model-cache";
37
38
  import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
38
39
  import {
@@ -177,6 +178,7 @@ export function knownModelIdsForProvider(
177
178
  if (!account || account.needsReauth || !Number.isFinite(account.credential.expires)
178
179
  || account.credential.expires <= Date.now()) return undefined;
179
180
  key = account.credential.access;
181
+ if (provName === "workbuddy-ai") return workBuddyCatalogIdentity(key, account.credential.workbuddy);
180
182
  if (routed.adapter === "devin") destination = validateDevinApiBaseUrl(account.credential.apiBaseUrl) ?? routed.baseUrl;
181
183
  }
182
184
  if (!key) return undefined;
@@ -11,7 +11,7 @@
11
11
  * sample for the full history.
12
12
  */
13
13
 
14
- import type { PersistedUsageEntry } from "../usage/log";
14
+ import { normalizePersistedUsageRow, type PersistedUsageEntry } from "../usage/log";
15
15
  import { computeEntryCost } from "../usage/summary";
16
16
  import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
17
17
 
@@ -141,8 +141,7 @@ function classifyRow(row: ScannedRow): "success" | "failure" | "cancelled" {
141
141
 
142
142
  function parseEntry(rowJson: string): PersistedUsageEntry | null {
143
143
  try {
144
- const parsed = JSON.parse(rowJson) as PersistedUsageEntry;
145
- return parsed && typeof parsed === "object" && typeof parsed.requestId === "string" ? parsed : null;
144
+ return normalizePersistedUsageRow(JSON.parse(rowJson)) ?? null;
146
145
  } catch {
147
146
  return null;
148
147
  }
@@ -192,9 +192,9 @@ export type FallbackGuardResult =
192
192
  ok: true;
193
193
  kind: OcxFallbackKind | null;
194
194
  /**
195
- * Record the hop as taken. A site calls it once the fallback dispatch is admitted (send
196
- * budget reserved, candidate not locally skipped), so the ring never claims a target that was
197
- * never contacted. Idempotent; a no-op for a same-target retry.
195
+ * Record the hop as taken at the site's admission boundary (body rewrite, send-budget
196
+ * reservation, or accepted candidate). This does not prove upstream contact or billing;
197
+ * later preparation may still refuse. Idempotent; a no-op for a same-target retry.
198
198
  */
199
199
  commit: () => void;
200
200
  }
@@ -1195,6 +1195,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1195
1195
  desktopExecutor: "redacted",
1196
1196
  unsafeAllowNativeLocalExec: "editor",
1197
1197
  nativeLocalExec: "editor",
1198
+ messagePhaseInference: "editor",
1198
1199
  tlsProfile: "editor",
1199
1200
  } as const satisfies Record<keyof OcxProviderConfig, ProviderConfigFieldPolicy>;
1200
1201
 
@@ -40,6 +40,9 @@ import { evidenceFromBody } from "../routing/request-evidence";
40
40
  import { resolveWireProtocolOverride } from "./adapter-resolve";
41
41
  import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
42
42
  import {
43
+ assignNativeLogConversationId,
44
+ fillHermesLogConversationFallback,
45
+ nativeLogConversationIdFromHeaders,
43
46
  getOrAllocateRequestSessionLane,
44
47
  linkRequestSessionLane,
45
48
  } from "./request-log-conversation";
@@ -69,7 +72,7 @@ import {
69
72
  assertRouteAllowedByScope,
70
73
  resolveAdmissionModelScope,
71
74
  } from "./admission-model-scope";
72
- import type { DataPlaneAdmission } from "./auth-cors";
75
+ import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
73
76
  import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
74
77
  import {
75
78
  createTranslatorBudget,
@@ -114,6 +117,10 @@ export async function handleChatCompletions(
114
117
  logCtx: RequestLogContext,
115
118
  logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission },
116
119
  ): Promise<Response> {
120
+ // Set before routing so pre-dispatch refusals stay correlated. A Hermes-only id is marked as a
121
+ // fallback, so a native id resolved later on the Responses bridge still replaces it.
122
+ assignNativeLogConversationId(logCtx, nativeLogConversationIdFromHeaders(req.headers));
123
+ fillHermesLogConversationFallback(logCtx, req.headers);
117
124
  const translatorBudget = createTranslatorBudget();
118
125
  try {
119
126
  let response = await handleChatCompletionsWithBudget(req, config, logCtx, translatorBudget, logIds);
@@ -420,6 +427,7 @@ async function handleChatCompletionsWithBudget(
420
427
  // Unresolved combos are checked after their concrete child route is selected in Responses.
421
428
  if (settledRoute && !settledRoute.combo && isCanonicalOpenAiForwardProvider(settledRoute.provider)
422
429
  && isCodexReserveHelperUnsupported(config, settledRoute.modelId, logIds?.admission, visionDescribeTerminal)) {
430
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 400, { closeReason: "non_stream" });
423
431
  return chatCompletionsErrorResponse(400, CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, "invalid_request_error");
424
432
  }
425
433
  const nativeCallerAuth = captureExplicitOpenAiCallerAuth(req.headers, config);
@@ -498,6 +506,8 @@ async function handleChatCompletionsWithBudget(
498
506
  // #1686: the Chat surface translates its body and replays here, so the admission fact has
499
507
  // to ride along or a bearer-admitted Chat caller would still be refused by Direct.
500
508
  ...(logIds?.admission ? { admission: logIds.admission } : {}),
509
+ // The bridge request carries no caller credential, so resolve the replay principal here.
510
+ replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
501
511
  abortSignal: req.signal,
502
512
  // Body is Responses-shaped by now, but the client spoke Chat Completions.
503
513
  inboundWire: "chat",
@@ -512,6 +522,9 @@ async function handleChatCompletionsWithBudget(
512
522
  ...(envelope && nativeChatCombos ? {
513
523
  protocolSource: createNativeChatComboSource({
514
524
  req, config, envelope, requestedModel, requestedStream: stream, translatorBudget,
525
+ // Children run without logIds; the request's admission rides the source so their
526
+ // replay scope resolves the same caller principal as every other surface.
527
+ ...(logIds?.admission ? { admission: logIds.admission } : {}),
515
528
  }),
516
529
  } : {}),
517
530
  // Terminal vision-describe marker (roadmap 180): the bridge rebuilds
@@ -7,11 +7,17 @@ import { reasoningReplayConversationIdFromResponsesRequest, sessionIdHeaderFromR
7
7
 
8
8
  type Rec = Record<string, unknown>;
9
9
 
10
- export function nativeChatReplayScope(headers: Headers): OcxReasoningReplayScopeRef | undefined {
10
+ export function nativeChatReplayScope(
11
+ headers: Headers,
12
+ clientPrincipalId: string | undefined,
13
+ ): OcxReasoningReplayScopeRef | undefined {
11
14
  const clientThreadId = reasoningReplayConversationIdFromResponsesRequest({
12
15
  threadIdHeader: headers.get("thread-id"), sessionIdHeader: sessionIdHeaderFromRequest(headers),
13
16
  });
14
- return clientThreadId ? { clientThreadId } : undefined;
17
+ return clientThreadId ? {
18
+ clientThreadId,
19
+ ...(clientPrincipalId ? { clientPrincipalId } : {}),
20
+ } : undefined;
15
21
  }
16
22
 
17
23
  /** Rebind before EVERY build: a key rotation must not retrieve another credential's reasoning. */
@@ -42,7 +48,8 @@ export function nativeChatReasoningCapture(
42
48
  const maxBytes = 256 * 1024;
43
49
  let bytes = 0;
44
50
  let callCount = 0;
45
- let disabled = !scope?.current || !chatReasoningReplayPolicy(provider, modelId).preserve;
51
+ let disabled = !scope?.clientPrincipalId || !scope.current
52
+ || !chatReasoningReplayPolicy(provider, modelId).preserve;
46
53
  const discard = () => {
47
54
  budget.releaseRetained(bytes, accounting);
48
55
  bytes = 0;
@@ -24,6 +24,7 @@ import {
24
24
  } from "../lib/errors";
25
25
  import { reportDispatchSends, type RequestExecutionBudget, type SingleUseDispatchPermit } from "../lib/request-execution-budget";
26
26
  import type { AdmissionLease } from "../lib/admission";
27
+ import { resolveContextPrincipal, type DataPlaneAdmission } from "./auth-cors";
27
28
  import { readBoundedResponseBody } from "../lib/bounded-body";
28
29
  import { redactSecretString } from "../lib/redact";
29
30
  import { resolveClientRetryAfter } from "../lib/retry-after";
@@ -179,7 +180,7 @@ export interface HandleNativeChatOptions {
179
180
  req: Request;
180
181
  config: OcxConfig;
181
182
  logCtx: RequestLogContext;
182
- logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease };
183
+ logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease; admission?: DataPlaneAdmission };
183
184
  route: RouteResult;
184
185
  chatBody: Rec;
185
186
  requestedModel: string;
@@ -209,6 +210,12 @@ export interface NativeChatExecution extends HandleNativeChatOptions {
209
210
  onFirstOutput?: () => void;
210
211
  /** The lease a streamed body holds; defaults to `logIds.turnAdmissionLease`. */
211
212
  turnAdmissionLease?: AdmissionLease;
213
+ /**
214
+ * The request's resolved data-plane admission, when the caller carries it without `logIds`.
215
+ * A combo child runs on the parent's log row, so it receives the admitted identity here or
216
+ * its reasoning replay scope can never establish a caller principal (PF-07).
217
+ */
218
+ admission?: DataPlaneAdmission;
212
219
  }
213
220
 
214
221
  /**
@@ -246,6 +253,8 @@ export function createNativeChatComboSource(input: {
246
253
  requestedModel: string;
247
254
  requestedStream: boolean;
248
255
  translatorBudget: TranslatorBudget;
256
+ /** The request's data-plane admission; native children inherit it for replay scope. */
257
+ admission?: DataPlaneAdmission;
249
258
  }): ComboProtocolSource {
250
259
  return {
251
260
  inbound: "chat",
@@ -264,6 +273,7 @@ export function createNativeChatComboSource(input: {
264
273
  comboDispatchPermit: child.comboDispatchPermit,
265
274
  onFirstOutput: child.onFirstOutput,
266
275
  ...(child.turnAdmissionLease ? { turnAdmissionLease: child.turnAdmissionLease } : {}),
276
+ ...(input.admission ? { admission: input.admission } : {}),
267
277
  }, child.attemptHandle),
268
278
  };
269
279
  }
@@ -340,7 +350,8 @@ export async function runNativeChatAttempt(
340
350
  const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
341
351
  let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider);
342
352
  let activeRequest: AdapterRequest;
343
- const replayScope = nativeChatReplayScope(req.headers);
353
+ const replayScope = nativeChatReplayScope(req.headers,
354
+ resolveContextPrincipal(req, config, execution.admission ?? logIds?.admission));
344
355
  let retainedRequestBytes = 0;
345
356
  const releaseRetainedRequest = () => {
346
357
  if (retainedRequestBytes === 0) return;
@@ -100,6 +100,7 @@ import {
100
100
  isApiAuthRequired,
101
101
  isDataPlaneAdmissionSecret,
102
102
  isProxyAdmissionSecret,
103
+ resolveContextPrincipal,
103
104
  type RequestPolicyView,
104
105
  type DataPlaneAdmission,
105
106
  } from "./auth-cors";
@@ -1117,6 +1118,8 @@ async function handleClaudeMessagesWithBudget(
1117
1118
  // Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
1118
1119
  codexAuthPolicy: config,
1119
1120
  ...(logIds?.admission ? { admission: logIds.admission } : {}),
1121
+ // The translated request never forwards the caller credential; resolve the principal first.
1122
+ replayPrincipal: { clientPrincipalId: resolveContextPrincipal(req, config, logIds?.admission) },
1120
1123
  ...(logIds?.turnAdmissionLease ? { turnAdmissionLease: logIds.turnAdmissionLease } : {}),
1121
1124
  abortSignal: req.signal,
1122
1125
  promptCacheKeyIsSharedCohort: cacheKeySource === "system",