@bitkyc08/opencodex 2.90.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 (250) hide show
  1. package/bin/ocx.mjs +37 -3
  2. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  3. package/gui/dist/assets/{App-DgxkE6Md.js → App-sUf_8afJ.js} +18 -18
  4. package/gui/dist/assets/{Tray-DbTNc_88.js → Tray-BSsxznDm.js} +1 -1
  5. package/gui/dist/assets/index-BlFE_VB_.js +94 -0
  6. package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
  7. package/gui/dist/index.html +1 -1
  8. package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
  9. package/package.json +4 -3
  10. package/src/adapters/anthropic.ts +2 -5
  11. package/src/adapters/claude-cli/usage-admission.ts +14 -0
  12. package/src/adapters/google-vertex-endpoint.ts +9 -1
  13. package/src/adapters/google-vertex-key-project.ts +223 -0
  14. package/src/adapters/google.ts +13 -3
  15. package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
  16. package/src/adapters/openai-chat.ts +2 -2
  17. package/src/adapters/openai-responses/passthrough.ts +2 -1
  18. package/src/adapters/openai-responses/request-strips.ts +92 -0
  19. package/src/adapters/openai-responses/transport.ts +6 -0
  20. package/src/adapters/registry.ts +5 -0
  21. package/src/adapters/workbuddy-schema.ts +61 -0
  22. package/src/adapters/workbuddy.ts +68 -0
  23. package/src/bridge/response-json.ts +1 -1
  24. package/src/bridge/sse.ts +1 -1
  25. package/src/chat/inbound.ts +20 -2
  26. package/src/chat/outbound.ts +47 -8
  27. package/src/chat/reasoning-details.ts +137 -0
  28. package/src/cli/capabilities-agents-routing.ts +53 -3
  29. package/src/cli/capabilities-base.ts +15 -2
  30. package/src/cli/capabilities-observe-system.ts +4 -4
  31. package/src/cli/codex-cli-update.ts +8 -0
  32. package/src/cli/codex-shim-autorestore.ts +5 -0
  33. package/src/cli/combo-result.ts +6 -0
  34. package/src/cli/combo-stats.ts +3 -0
  35. package/src/cli/combo.ts +66 -42
  36. package/src/cli/dispatch.ts +30 -0
  37. package/src/cli/fallback.ts +127 -0
  38. package/src/cli/help.ts +1 -0
  39. package/src/cli/index.ts +1 -2
  40. package/src/cli/launcher-context.ts +49 -2
  41. package/src/cli/link-apply-restart.ts +72 -0
  42. package/src/cli/link-child.ts +6 -2
  43. package/src/cli/link-cleanup.ts +95 -0
  44. package/src/cli/link-enrollment.ts +4 -0
  45. package/src/cli/link.ts +123 -35
  46. package/src/cli/provider.ts +46 -3
  47. package/src/cli/registry.ts +13 -1
  48. package/src/cli/restart-failure.ts +4 -0
  49. package/src/cli/runtime-api.ts +1 -1
  50. package/src/cli/status-codex-settings.ts +56 -0
  51. package/src/cli/status-fallback.ts +29 -0
  52. package/src/cli/status-install.ts +52 -0
  53. package/src/cli/status.ts +20 -1
  54. package/src/cli/system-restart-client.ts +32 -1
  55. package/src/cli/update-restart-child.ts +4 -3
  56. package/src/cli/update-restart-home.ts +20 -8
  57. package/src/cli/usage-compare.ts +68 -12
  58. package/src/client/connect.ts +82 -6
  59. package/src/client/home-unreachable.ts +16 -0
  60. package/src/client/hub-relay.ts +8 -2
  61. package/src/client/link-home.ts +119 -6
  62. package/src/client/link-ingress.ts +1 -1
  63. package/src/client/link-parity-reporter.ts +107 -0
  64. package/src/client/link-readiness.ts +97 -0
  65. package/src/client/link-relay-transport.ts +84 -9
  66. package/src/client/link-relay.ts +98 -15
  67. package/src/client/link-tunnel.ts +45 -18
  68. package/src/client/machine-listener.ts +103 -3
  69. package/src/client/runtime-restart.ts +127 -0
  70. package/src/client/runtime.ts +155 -25
  71. package/src/codex/account-store.ts +17 -3
  72. package/src/codex/app-server-restart-service.ts +6 -5
  73. package/src/codex/auth-context.ts +10 -6
  74. package/src/codex/catalog/effort.ts +9 -2
  75. package/src/codex/catalog/gather-capture.ts +4 -2
  76. package/src/codex/catalog/parsing.ts +4 -0
  77. package/src/codex/catalog/provider-models.ts +14 -8
  78. package/src/codex/chatgpt-refresh-failure.ts +6 -2
  79. package/src/codex/cli-install-provenance.ts +418 -0
  80. package/src/codex/cli-installation-targets.ts +5 -1
  81. package/src/codex/cli-update-plan.ts +43 -16
  82. package/src/codex/desktop-app/picker-freshness.ts +10 -8
  83. package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
  84. package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
  85. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  86. package/src/codex/pool-refresh-backoff.ts +3 -1
  87. package/src/codex/quota-history.ts +7 -0
  88. package/src/codex/quota.ts +10 -0
  89. package/src/codex/subagent-model-fallback.ts +24 -1
  90. package/src/codex/user-identity.ts +35 -6
  91. package/src/codex/windows-current-sid.ts +162 -0
  92. package/src/combos/index.ts +1 -0
  93. package/src/combos/jev-quota-activation.ts +5 -1
  94. package/src/combos/jev-quota-route.ts +1 -1
  95. package/src/combos/jev-quota.ts +9 -1
  96. package/src/combos/jev-session-pin-config.ts +51 -0
  97. package/src/combos/jev.ts +6 -0
  98. package/src/combos/resolve.ts +12 -0
  99. package/src/combos/types.ts +19 -0
  100. package/src/config/diagnostics.ts +5 -0
  101. package/src/config/load-degrade.ts +2 -0
  102. package/src/config/provider-validation.ts +7 -0
  103. package/src/config/schema/config-schema.ts +3 -0
  104. package/src/config/schema/fallback-policy.ts +40 -0
  105. package/src/config/schema/leaf-validators.ts +1 -0
  106. package/src/generated/compatibility-version.json +375 -195
  107. package/src/lib/authorization-scheme.ts +95 -0
  108. package/src/lib/decision-quota-sink.ts +10 -0
  109. package/src/lib/provider-client-headers.ts +1 -0
  110. package/src/lib/redact.ts +2 -2
  111. package/src/lib/system-restart-contract.ts +24 -0
  112. package/src/lib/test-home-guard.ts +78 -11
  113. package/src/lib/translator-budget.ts +4 -4
  114. package/src/lib/upstream-retry.ts +4 -1
  115. package/src/link/child-parity.ts +124 -0
  116. package/src/link/codex-settings.ts +37 -0
  117. package/src/link/compatibility.ts +119 -0
  118. package/src/link/diagnostics.ts +193 -0
  119. package/src/link/management-routes.ts +2 -0
  120. package/src/link/peer-report.ts +100 -0
  121. package/src/link/relay-auth.ts +6 -0
  122. package/src/link/remote-platform.ts +78 -0
  123. package/src/link/ssh-argv.ts +19 -0
  124. package/src/link/status-projection.ts +63 -2
  125. package/src/link/supervisor.ts +45 -12
  126. package/src/link/tunnel-history.ts +109 -0
  127. package/src/link/tunnel-state.ts +80 -17
  128. package/src/messaging/remote-files.ts +24 -1
  129. package/src/oauth/generic-account-failover.ts +91 -7
  130. package/src/oauth/index.ts +16 -1
  131. package/src/oauth/kimi.ts +2 -0
  132. package/src/oauth/model-discovery-auth.ts +5 -0
  133. package/src/oauth/nous.ts +4 -1
  134. package/src/oauth/store.ts +6 -2
  135. package/src/oauth/token-guardian.ts +7 -0
  136. package/src/oauth/types.ts +9 -0
  137. package/src/oauth/workbuddy.ts +139 -0
  138. package/src/protocols/codecs/chat.ts +3 -3
  139. package/src/protocols/encoders/adapter-events.ts +26 -2
  140. package/src/protocols/encoders/chat.ts +55 -3
  141. package/src/providers/antigravity-quota-identity.ts +37 -0
  142. package/src/providers/base-url-choices.ts +8 -5
  143. package/src/providers/derive.ts +23 -3
  144. package/src/providers/mistral-transport.ts +37 -4
  145. package/src/providers/model-discovery.ts +2 -0
  146. package/src/providers/model-rename-fields.ts +1 -0
  147. package/src/providers/quota/account-cache.ts +3 -2
  148. package/src/providers/quota/antigravity.ts +27 -22
  149. package/src/providers/quota/report-cache.ts +13 -4
  150. package/src/providers/quota/vendor-probes-key.ts +60 -0
  151. package/src/providers/quota/workbuddy.ts +83 -0
  152. package/src/providers/quota-decision-antigravity.ts +64 -0
  153. package/src/providers/quota-decision-publication.ts +11 -0
  154. package/src/providers/quota-decision-snapshot.ts +21 -4
  155. package/src/providers/quota-types.ts +2 -0
  156. package/src/providers/quota.ts +3 -1
  157. package/src/providers/registry/entries-core.ts +9 -0
  158. package/src/providers/registry/entries-extended.ts +20 -7
  159. package/src/providers/registry/model-ids.ts +3 -0
  160. package/src/providers/registry/model-seeds.ts +27 -28
  161. package/src/providers/registry/types.ts +19 -1
  162. package/src/providers/resolved-model-policy.ts +4 -4
  163. package/src/providers/routed-config.ts +15 -0
  164. package/src/providers/stale-model-roster-migration.ts +83 -15
  165. package/src/providers/workbuddy-credit-balance.ts +81 -0
  166. package/src/providers/workbuddy-models.ts +37 -0
  167. package/src/providers/workbuddy.ts +60 -0
  168. package/src/quota-pool/assemble.ts +326 -0
  169. package/src/quota-pool/contract.ts +58 -0
  170. package/src/quota-pool/identity.ts +203 -0
  171. package/src/remote/features.ts +74 -0
  172. package/src/remote/protocol.ts +2 -0
  173. package/src/remote/served-by.ts +57 -0
  174. package/src/responses/parser.ts +7 -0
  175. package/src/responses/reasoning-envelope.ts +12 -0
  176. package/src/responses/reasoning-replay-cache.ts +18 -14
  177. package/src/router.ts +2 -0
  178. package/src/routing/analytics.ts +2 -3
  179. package/src/routing/fallback-policy.ts +290 -0
  180. package/src/routing/fallback-state.ts +179 -0
  181. package/src/server/audio-client.ts +2 -1
  182. package/src/server/audio-dictation.ts +2 -1
  183. package/src/server/audio-upstream.ts +2 -1
  184. package/src/server/auth-cors.ts +7 -5
  185. package/src/server/background-lifecycle.ts +4 -0
  186. package/src/server/chat-completions.ts +22 -2
  187. package/src/server/chat-native-reasoning.ts +10 -3
  188. package/src/server/chat-native.ts +13 -2
  189. package/src/server/claude-messages.ts +3 -0
  190. package/src/server/gui-session.ts +2 -1
  191. package/src/server/index/link-listener.ts +11 -1
  192. package/src/server/index/link-management.ts +47 -0
  193. package/src/server/index/link-relay-sessions.ts +20 -7
  194. package/src/server/index/optional-listeners.ts +11 -2
  195. package/src/server/index/serve-options.ts +19 -2
  196. package/src/server/live.ts +5 -0
  197. package/src/server/local-management-read-client.ts +3 -1
  198. package/src/server/management/combo-routes.ts +10 -4
  199. package/src/server/management/context.ts +1 -1
  200. package/src/server/management/fallback-routes.ts +75 -0
  201. package/src/server/management/link-enrollment.ts +21 -3
  202. package/src/server/management/link-route-state.ts +2 -0
  203. package/src/server/management/link-routes.ts +16 -2
  204. package/src/server/management/provider-routes.ts +1 -1
  205. package/src/server/management/quota-pool-routes.ts +24 -0
  206. package/src/server/management/route-registry.ts +3 -0
  207. package/src/server/management-api.ts +16 -0
  208. package/src/server/management-auth.ts +2 -1
  209. package/src/server/port-reclaim.ts +51 -9
  210. package/src/server/request-log-conversation.ts +49 -0
  211. package/src/server/request-log.ts +2 -0
  212. package/src/server/responses/agent-task-recovery.ts +1 -1
  213. package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
  214. package/src/server/responses/compact.ts +12 -4
  215. package/src/server/responses/core-auth.ts +11 -1
  216. package/src/server/responses/core-combo.ts +125 -32
  217. package/src/server/responses/core-lifetime.ts +2 -2
  218. package/src/server/responses/core-normalize.ts +6 -3
  219. package/src/server/responses/core-opaque-recovery.ts +1 -0
  220. package/src/server/responses/core-options.ts +5 -0
  221. package/src/server/responses/core-replay.ts +2 -1
  222. package/src/server/responses/encrypted-payload.ts +3 -3
  223. package/src/server/responses/jev-combo-choice.ts +39 -0
  224. package/src/server/responses/jev-session-pin.ts +203 -0
  225. package/src/server/responses/main-hard-lock-routing.ts +54 -2
  226. package/src/server/responses/passthrough-delivery.ts +4 -0
  227. package/src/server/responses/policy-fallback.ts +44 -1
  228. package/src/server/responses/request-prepare.ts +19 -6
  229. package/src/server/responses/request-transport.ts +25 -6
  230. package/src/server/responses/run-turn-execution.ts +2 -4
  231. package/src/server/responses/sidecar-execution.ts +2 -4
  232. package/src/server/responses-phase-inference.ts +267 -0
  233. package/src/server/restart-replacement.ts +50 -2
  234. package/src/server/sse-payload-rewrite.ts +14 -1
  235. package/src/server/ws-bridge.ts +5 -0
  236. package/src/service/cli.ts +25 -2
  237. package/src/service/definition.ts +20 -8
  238. package/src/service/diagnostics.ts +20 -6
  239. package/src/service/status-json.ts +116 -0
  240. package/src/types/config.ts +13 -0
  241. package/src/types/fallback.ts +38 -0
  242. package/src/types/provider.ts +9 -1
  243. package/src/types/request.ts +1 -0
  244. package/src/types/wire.ts +1 -0
  245. package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
  246. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  247. package/src/usage/jev-stats.ts +54 -14
  248. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  249. package/gui/dist/assets/index-TmaHa54u.js +0 -94
  250. package/gui/dist/assets/quota-summary-B16x5DZZ.js +0 -1
@@ -0,0 +1,290 @@
1
+ /**
2
+ * OCX-11 fallback policy: one resolver, one guard, one record.
3
+ *
4
+ * A fallback is a hop that serves a request from a different model or a different provider than
5
+ * the one chosen or requested. Rotation among the accounts or keys of one provider pool for the
6
+ * same model is intra-pool resilience and never passes through here (see
7
+ * `structure/fallback-policy.md`). Every hop site calls {@link guardFallbackHop} at the point it
8
+ * would switch targets; nothing else reads `config.fallback`.
9
+ *
10
+ * Precedence: a device scope set to false refuses regardless; otherwise route, then provider,
11
+ * then global decide, most specific first; absence allows (today's behavior).
12
+ *
13
+ * Privacy: events carry public selectors, coarse trigger codes and device ids only. No request
14
+ * body, credential, account id, label or email reaches the ring or the refusal body.
15
+ */
16
+ import type { OcxConfig } from "../types";
17
+ import type { OcxFallbackConfig, OcxFallbackKind, OcxFallbackSwitches } from "../types/fallback";
18
+ import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy";
19
+
20
+ export type FallbackScope = "device" | "route" | "provider" | "global" | "default";
21
+ export type FallbackSite = "combo" | "subagent" | "hard-lock" | "routing-profile";
22
+ export type FallbackTrigger =
23
+ | "rate_limited" | "quota_exhausted" | "auth_refused" | "upstream_5xx" | "upstream_error"
24
+ | "cooldown" | "unavailable" | "hard_lock";
25
+
26
+ export interface FallbackTarget { provider: string; model: string }
27
+
28
+ export interface FallbackDecision {
29
+ allowed: boolean;
30
+ scope: FallbackScope;
31
+ scopeKey?: string;
32
+ }
33
+
34
+ export interface FallbackEvent {
35
+ at: string;
36
+ site: FallbackSite;
37
+ route: string;
38
+ kind: OcxFallbackKind;
39
+ from: string;
40
+ to: string;
41
+ trigger: FallbackTrigger;
42
+ outcome: "taken" | "refused";
43
+ scope: FallbackScope;
44
+ scopeKey?: string;
45
+ deviceId?: string;
46
+ }
47
+
48
+ /** The admission slice this module reads; a configured key is the only device identity. */
49
+ export type FallbackAdmission = { kind: string; keyId?: string } | null | undefined;
50
+
51
+ const RING_SIZE = 20;
52
+ const ring: FallbackEvent[] = [];
53
+
54
+ function switches(value: unknown): OcxFallbackSwitches | undefined {
55
+ return value && typeof value === "object" && !Array.isArray(value) ? value as OcxFallbackSwitches : undefined;
56
+ }
57
+
58
+ function policyOf(config: Pick<OcxConfig, "fallback"> | undefined): OcxFallbackConfig | undefined {
59
+ const value = config?.fallback;
60
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
61
+ }
62
+
63
+ function entry(map: Record<string, unknown> | undefined, key: string, pattern: RegExp): OcxFallbackSwitches | undefined {
64
+ if (!map || typeof map !== "object" || !pattern.test(key) || !Object.hasOwn(map, key)) return undefined;
65
+ return switches(map[key]);
66
+ }
67
+
68
+ export function selector(target: FallbackTarget): string {
69
+ return target.provider + "/" + target.model;
70
+ }
71
+
72
+ /** The kind of a hop, or null when the target does not change. */
73
+ export function fallbackHopKind(from: FallbackTarget, to: FallbackTarget): OcxFallbackKind | null {
74
+ if (from.provider !== to.provider) return "provider";
75
+ return from.model === to.model ? null : "model";
76
+ }
77
+
78
+ /** Resolve one hop against the configured scopes. Pure; device ids are passed in. */
79
+ export function resolveFallbackPolicy(
80
+ config: Pick<OcxConfig, "fallback"> | undefined,
81
+ query: { kind: OcxFallbackKind; routes?: readonly string[]; provider?: string; deviceIds?: readonly string[] },
82
+ ): FallbackDecision {
83
+ const policy = policyOf(config);
84
+ if (!policy) return { allowed: true, scope: "default" };
85
+ for (const id of query.deviceIds ?? []) {
86
+ if (entry(policy.devices, id, FALLBACK_DEVICE_ID)?.[query.kind] === false) {
87
+ return { allowed: false, scope: "device", scopeKey: id };
88
+ }
89
+ }
90
+ for (const route of query.routes ?? []) {
91
+ const value = entry(policy.routes, route, FALLBACK_SELECTOR)?.[query.kind];
92
+ if (typeof value === "boolean") return { allowed: value, scope: "route", scopeKey: route };
93
+ }
94
+ if (query.provider) {
95
+ const value = entry(policy.providers, query.provider, FALLBACK_SELECTOR)?.[query.kind];
96
+ if (typeof value === "boolean") return { allowed: value, scope: "provider", scopeKey: query.provider };
97
+ }
98
+ const global = policy[query.kind];
99
+ if (typeof global === "boolean") return { allowed: global, scope: "global" };
100
+ return { allowed: true, scope: "default" };
101
+ }
102
+
103
+ /**
104
+ * Core-owned slot (AGENTS.md "optional subsystems stay off the core path"): the link listener
105
+ * lifecycle registers how a data key id maps to its Remote Link id, so this request-path module
106
+ * never imports link state. With nothing registered a key id is its own device id (a Hub client).
107
+ */
108
+ export type FallbackLinkResolver = (keyId: string) => string | undefined;
109
+ let linkResolver: FallbackLinkResolver | undefined;
110
+
111
+ /** Register the key-to-link resolver; returns a disposer that clears it if still current. */
112
+ export function registerFallbackLinkResolver(resolver: FallbackLinkResolver): () => void {
113
+ linkResolver = resolver;
114
+ linkCache = undefined;
115
+ return () => {
116
+ if (linkResolver === resolver) linkResolver = undefined;
117
+ linkCache = undefined;
118
+ };
119
+ }
120
+
121
+ let linkCache: { at: number; byKey: Map<string, string | undefined> } | undefined;
122
+ const LINK_CACHE_MS = 5_000;
123
+
124
+ /** Test seam: forget the cached link-key map. */
125
+ export function resetFallbackLinkCacheForTests(): void {
126
+ linkCache = undefined;
127
+ }
128
+
129
+ function linkIdForKey(keyId: string, now: number): string | undefined {
130
+ if (!linkResolver) return undefined;
131
+ if (!linkCache || now - linkCache.at > LINK_CACHE_MS) linkCache = { at: now, byKey: new Map() };
132
+ if (!linkCache.byKey.has(keyId)) {
133
+ let linkId: string | undefined;
134
+ try { linkId = linkResolver(keyId); } catch { /* unreadable store: the key id alone still identifies the device */ }
135
+ linkCache.byKey.set(keyId, linkId);
136
+ }
137
+ return linkCache.byKey.get(keyId);
138
+ }
139
+
140
+ /**
141
+ * Device ids for a request: the Remote Link id its data key belongs to, then the key id itself
142
+ * (a Hub client). The registered link resolver runs only when a device override exists.
143
+ */
144
+ export function fallbackDeviceIds(config: Pick<OcxConfig, "fallback">, admission: FallbackAdmission, now = Date.now()): string[] {
145
+ const devices = policyOf(config)?.devices;
146
+ if (!devices || Object.keys(devices).length === 0) return [];
147
+ if (!admission || admission.kind !== "configured" || typeof admission.keyId !== "string") return [];
148
+ const linkId = linkIdForKey(admission.keyId, now);
149
+ return linkId ? [linkId, admission.keyId] : [admission.keyId];
150
+ }
151
+
152
+ /** A coarse trigger code for a failed upstream status. */
153
+ export function fallbackTriggerForStatus(status: number): FallbackTrigger {
154
+ if (status === 429) return "rate_limited";
155
+ if (status === 402) return "quota_exhausted";
156
+ if (status === 401 || status === 403) return "auth_refused";
157
+ if (status >= 500) return "upstream_5xx";
158
+ return "upstream_error";
159
+ }
160
+
161
+ function record(event: FallbackEvent): void {
162
+ ring.push(event);
163
+ if (ring.length > RING_SIZE) ring.splice(0, ring.length - RING_SIZE);
164
+ }
165
+
166
+ export function recentFallbackEvents(): FallbackEvent[] {
167
+ return ring.map(event => ({ ...event }));
168
+ }
169
+
170
+ export function clearFallbackEventsForTests(): void {
171
+ ring.length = 0;
172
+ }
173
+
174
+ export interface FallbackHop {
175
+ site: FallbackSite;
176
+ /** The public selector the client asked for (combo/<id>, policy/<id>, a model). */
177
+ route: string;
178
+ /** Further selectors that name the same route, such as a resolved alias. */
179
+ aliases?: readonly string[];
180
+ from: FallbackTarget;
181
+ to: FallbackTarget;
182
+ trigger: FallbackTrigger;
183
+ admission?: FallbackAdmission;
184
+ /** Status of the failure that triggered the hop; absent for a proactive hop. */
185
+ failureStatus?: number;
186
+ retryAfter?: string | null;
187
+ now?: number;
188
+ }
189
+
190
+ export type FallbackGuardResult =
191
+ | {
192
+ ok: true;
193
+ kind: OcxFallbackKind | null;
194
+ /**
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
+ */
199
+ commit: () => void;
200
+ }
201
+ | { ok: false; kind: OcxFallbackKind; decision: FallbackDecision; response: Response };
202
+
203
+ const NO_COMMIT = (): void => {};
204
+
205
+ /**
206
+ * Check one hop. A refusal is recorded at once; an allowed hop is recorded only through the
207
+ * returned `commit`. A same-target retry is not a hop and always passes unrecorded.
208
+ */
209
+ export function guardFallbackHop(config: OcxConfig, hop: FallbackHop): FallbackGuardResult {
210
+ const kind = fallbackHopKind(hop.from, hop.to);
211
+ if (kind === null) return { ok: true, kind: null, commit: NO_COMMIT };
212
+ const now = hop.now ?? Date.now();
213
+ const deviceIds = fallbackDeviceIds(config, hop.admission, now);
214
+ const routes = [hop.route, ...(hop.aliases ?? [])].filter((value, index, all) => value && all.indexOf(value) === index);
215
+ const decision = resolveFallbackPolicy(config, { kind, routes, provider: hop.from.provider, deviceIds });
216
+ const event: FallbackEvent = {
217
+ at: new Date(now).toISOString(),
218
+ site: hop.site,
219
+ route: hop.route,
220
+ kind,
221
+ from: selector(hop.from),
222
+ to: selector(hop.to),
223
+ trigger: hop.trigger,
224
+ outcome: decision.allowed ? "taken" : "refused",
225
+ scope: decision.scope,
226
+ ...(decision.scopeKey !== undefined ? { scopeKey: decision.scopeKey } : {}),
227
+ ...(deviceIds[0] !== undefined ? { deviceId: deviceIds[0] } : {}),
228
+ };
229
+ if (decision.allowed) {
230
+ let committed = false;
231
+ return { ok: true, kind, commit: () => { if (!committed) { committed = true; record(event); } } };
232
+ }
233
+ record(event);
234
+ return { ok: false, kind, decision, response: fallbackRefusedResponse(event, hop) };
235
+ }
236
+
237
+ function scopeFlag(event: FallbackEvent): string {
238
+ switch (event.scope) {
239
+ case "device": return " --device " + event.scopeKey;
240
+ case "route": return " --route " + event.scopeKey;
241
+ case "provider": return " --provider " + event.scopeKey;
242
+ default: return "";
243
+ }
244
+ }
245
+
246
+ function scopePhrase(event: FallbackEvent): string {
247
+ return event.scope === "global" ? "globally" : "for " + event.scope + " " + event.scopeKey;
248
+ }
249
+
250
+ /** The stable `fallback_refused` error. Status follows the triggering failure. */
251
+ export function fallbackRefusedResponse(event: FallbackEvent, hop: Pick<FallbackHop, "failureStatus" | "retryAfter">): Response {
252
+ const status = typeof hop.failureStatus === "number" && hop.failureStatus >= 400 && hop.failureStatus <= 599
253
+ ? hop.failureStatus
254
+ : event.trigger === "cooldown" || event.trigger === "quota_exhausted" || event.trigger === "rate_limited"
255
+ || event.trigger === "hard_lock" ? 429 : 503;
256
+ const kindWord = event.kind === "provider" ? "Provider" : "Model";
257
+ const message = event.route + ": " + event.from + " failed (" + event.trigger + "). " + kindWord
258
+ + " fallback to " + event.to + " is off " + scopePhrase(event) + ", so OpenCodex did not send the request there."
259
+ + " To allow it: ocx fallback on --kind " + event.kind + scopeFlag(event);
260
+ const headers = new Headers({ "x-opencodex-fallback": "refused", "cache-control": "no-store" });
261
+ if (hop.retryAfter) headers.set("retry-after", hop.retryAfter);
262
+ return Response.json({
263
+ error: {
264
+ message,
265
+ type: "fallback_refused",
266
+ code: "fallback_refused",
267
+ fallback: {
268
+ kind: event.kind,
269
+ scope: event.scope,
270
+ ...(event.scopeKey !== undefined ? { scopeKey: event.scopeKey } : {}),
271
+ from: event.from,
272
+ to: event.to,
273
+ trigger: event.trigger,
274
+ },
275
+ },
276
+ }, { status, headers });
277
+ }
278
+
279
+ /** Whether a response is this module's refusal. */
280
+ export function isFallbackRefusal(response: Response): boolean {
281
+ return response.headers.get("x-opencodex-fallback") === "refused";
282
+ }
283
+
284
+ /** Carries a refusal out of a site that cannot return a Response itself. */
285
+ export class FallbackRefusedError extends Error {
286
+ constructor(readonly response: Response) {
287
+ super("fallback_refused");
288
+ this.name = "FallbackRefusedError";
289
+ }
290
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * OCX-11 fallback state: the `ocx.fallback/1` read model and the one scoped write.
3
+ *
4
+ * Off the request path: the management route, `ocx fallback` and `ocx status --json` use it.
5
+ * The request path only resolves (`src/routing/fallback-policy.ts`).
6
+ */
7
+ import type { OcxConfig } from "../types";
8
+ import type { OcxFallbackConfig, OcxFallbackDeviceConfig, OcxFallbackSwitches } from "../types/fallback";
9
+ import { FALLBACK_DEVICE_ID, FALLBACK_SELECTOR } from "../config/schema/fallback-policy";
10
+ import type { LinkRecord } from "../link/store";
11
+ import { recentFallbackEvents, type FallbackEvent } from "./fallback-policy";
12
+
13
+ export const FALLBACK_STATE_SCHEMA = "ocx.fallback/1";
14
+
15
+ export interface FallbackDeviceView {
16
+ id: string;
17
+ kind: "link" | "key";
18
+ alias?: string;
19
+ /** Permission for GOAT to move this device's work to its own providers while the Home is down. */
20
+ localFallback: boolean;
21
+ /** false: refused for this device whatever the other scopes say; null: not set. */
22
+ model: boolean | null;
23
+ provider: boolean | null;
24
+ }
25
+
26
+ export interface FallbackStateView {
27
+ schema: typeof FALLBACK_STATE_SCHEMA;
28
+ /** Effective global switches; absence reads true, today's behavior. */
29
+ global: { model: boolean; provider: boolean };
30
+ providers: Record<string, OcxFallbackSwitches>;
31
+ routes: Record<string, OcxFallbackSwitches>;
32
+ devices: FallbackDeviceView[];
33
+ last: FallbackEvent | null;
34
+ recent: FallbackEvent[];
35
+ }
36
+
37
+ function cleanSwitches(value: unknown): OcxFallbackSwitches {
38
+ const out: OcxFallbackSwitches = {};
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) return out;
40
+ const raw = value as Record<string, unknown>;
41
+ if (typeof raw.model === "boolean") out.model = raw.model;
42
+ if (typeof raw.provider === "boolean") out.provider = raw.provider;
43
+ return out;
44
+ }
45
+
46
+ function cleanMap(value: unknown, pattern: RegExp): Record<string, OcxFallbackSwitches> {
47
+ const out: Record<string, OcxFallbackSwitches> = {};
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) return out;
49
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
50
+ if (!pattern.test(key)) continue;
51
+ const switches = cleanSwitches(entry);
52
+ if (Object.keys(switches).length > 0) out[key] = switches;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Build the read model. `passive` (a Remote Link Child's read through the management relay)
59
+ * drops link aliases and per-event device ids, so a Child learns its own row by link id only.
60
+ */
61
+ export function buildFallbackState(
62
+ config: Pick<OcxConfig, "fallback">,
63
+ options: { links?: readonly Pick<LinkRecord, "id" | "alias" | "apiKeyId">[]; passive?: boolean } = {},
64
+ ): FallbackStateView {
65
+ const policy: OcxFallbackConfig = config.fallback ?? {};
66
+ const devicesRaw = policy.devices && typeof policy.devices === "object" ? policy.devices : {};
67
+ const rows = new Map<string, FallbackDeviceView>();
68
+ const deviceRow = (id: string, kind: "link" | "key", alias?: string): FallbackDeviceView => {
69
+ const raw = (Object.hasOwn(devicesRaw, id) ? devicesRaw[id] : undefined) as OcxFallbackDeviceConfig | undefined;
70
+ return {
71
+ id,
72
+ kind,
73
+ ...(alias && !options.passive ? { alias } : {}),
74
+ localFallback: raw?.localFallback === true,
75
+ model: typeof raw?.model === "boolean" ? raw.model : null,
76
+ provider: typeof raw?.provider === "boolean" ? raw.provider : null,
77
+ };
78
+ };
79
+ for (const link of options.links ?? []) rows.set(link.id, deviceRow(link.id, "link", link.alias));
80
+ for (const id of Object.keys(devicesRaw)) {
81
+ if (!FALLBACK_DEVICE_ID.test(id) || rows.has(id)) continue;
82
+ rows.set(id, deviceRow(id, id.startsWith("lnk_") ? "link" : "key"));
83
+ }
84
+ const recent = recentFallbackEvents().map(event => {
85
+ if (!options.passive || event.deviceId === undefined) return event;
86
+ const { deviceId: _omitted, ...rest } = event;
87
+ return rest;
88
+ });
89
+ return {
90
+ schema: FALLBACK_STATE_SCHEMA,
91
+ global: { model: policy.model !== false, provider: policy.provider !== false },
92
+ providers: cleanMap(policy.providers, FALLBACK_SELECTOR),
93
+ routes: cleanMap(policy.routes, FALLBACK_SELECTOR),
94
+ devices: [...rows.values()],
95
+ last: recent.at(-1) ?? null,
96
+ recent,
97
+ };
98
+ }
99
+
100
+ export type FallbackWriteScope = "global" | "provider" | "route" | "device";
101
+ export type FallbackWriteSetting = "model" | "provider" | "localFallback";
102
+
103
+ export interface FallbackWrite {
104
+ scope: FallbackWriteScope;
105
+ key?: string;
106
+ setting: FallbackWriteSetting;
107
+ /** true or false sets the leaf; null removes it so the scope inherits again. */
108
+ value: boolean | null;
109
+ }
110
+
111
+ /** Validate a write body; the error text is operator-facing. */
112
+ export function parseFallbackWrite(body: unknown): { ok: true; write: FallbackWrite } | { ok: false; error: string } {
113
+ if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "expected a JSON object" };
114
+ const raw = body as Record<string, unknown>;
115
+ const scope = raw.scope;
116
+ const setting = raw.setting;
117
+ const value = raw.value;
118
+ if (scope !== "global" && scope !== "provider" && scope !== "route" && scope !== "device") {
119
+ return { ok: false, error: "scope must be global, provider, route or device" };
120
+ }
121
+ if (setting !== "model" && setting !== "provider" && setting !== "localFallback") {
122
+ return { ok: false, error: "setting must be model, provider or localFallback" };
123
+ }
124
+ if (value !== true && value !== false && value !== null) return { ok: false, error: "value must be true, false or null" };
125
+ if (setting === "localFallback" && scope !== "device") return { ok: false, error: "localFallback is set per device" };
126
+ if (scope === "global") {
127
+ if (raw.key !== undefined) return { ok: false, error: "a global write takes no key" };
128
+ return { ok: true, write: { scope, setting, value } };
129
+ }
130
+ const key = raw.key;
131
+ const pattern = scope === "device" ? FALLBACK_DEVICE_ID : FALLBACK_SELECTOR;
132
+ if (typeof key !== "string" || !pattern.test(key)) return { ok: false, error: scope + " key is missing or malformed" };
133
+ return { ok: true, write: { scope, key, setting, value } };
134
+ }
135
+
136
+ /** Up to 8 writes applied in one config mutation: `{ writes: [...] }`, or a single write. */
137
+ export const MAX_FALLBACK_WRITES = 8;
138
+
139
+ export function parseFallbackWrites(body: unknown): { ok: true; writes: FallbackWrite[] } | { ok: false; error: string } {
140
+ const list = body && typeof body === "object" && !Array.isArray(body) && Object.hasOwn(body, "writes")
141
+ ? (body as { writes: unknown }).writes
142
+ : [body];
143
+ if (!Array.isArray(list) || list.length === 0 || list.length > MAX_FALLBACK_WRITES) {
144
+ return { ok: false, error: "writes must hold 1 to " + MAX_FALLBACK_WRITES + " entries" };
145
+ }
146
+ const writes: FallbackWrite[] = [];
147
+ for (const entry of list) {
148
+ const parsed = parseFallbackWrite(entry);
149
+ if (!parsed.ok) return parsed;
150
+ writes.push(parsed.write);
151
+ }
152
+ return { ok: true, writes };
153
+ }
154
+
155
+ function setLeaf(target: Record<string, unknown>, setting: string, value: boolean | null): void {
156
+ if (value === null) delete target[setting];
157
+ else target[setting] = value;
158
+ }
159
+
160
+ /** Apply one write to a config in place; reports whether anything changed. */
161
+ export function applyFallbackWrite(config: OcxConfig, write: FallbackWrite): boolean {
162
+ const before = JSON.stringify(config.fallback ?? null);
163
+ const policy: Record<string, unknown> = { ...(config.fallback ?? {}) };
164
+ if (write.scope === "global") {
165
+ setLeaf(policy, write.setting, write.value);
166
+ } else {
167
+ const mapName = write.scope === "provider" ? "providers" : write.scope === "route" ? "routes" : "devices";
168
+ const map: Record<string, Record<string, unknown>> = { ...((policy[mapName] as Record<string, Record<string, unknown>> | undefined) ?? {}) };
169
+ const entry = { ...(Object.hasOwn(map, write.key!) ? map[write.key!] : {}) };
170
+ setLeaf(entry, write.setting, write.value);
171
+ if (Object.keys(entry).length > 0) map[write.key!] = entry;
172
+ else delete map[write.key!];
173
+ if (Object.keys(map).length > 0) policy[mapName] = map;
174
+ else delete policy[mapName];
175
+ }
176
+ if (Object.keys(policy).length > 0) config.fallback = policy as OcxFallbackConfig;
177
+ else delete config.fallback;
178
+ return JSON.stringify(config.fallback ?? null) !== before;
179
+ }
@@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
4
4
  import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
5
5
  import { isProxyAdmissionSecret, type DataPlaneAdmission, type DataPlaneAdmissionOptions } from "./auth-cors";
6
6
  import { resolveAudioAdmission } from "./audio-upstream";
7
+ import { authorizationCredential } from "../lib/authorization-scheme";
7
8
 
8
9
  export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio";
9
10
  const KEY_PROTOCOL_PREFIX = "opencodex-key.";
@@ -61,7 +62,7 @@ export function resolveAudioClient(
61
62
  : null;
62
63
  }
63
64
  const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key")
64
- : admission.source === "x-api-key" ? headers.get("x-api-key") : headers.get("authorization")?.replace(/^Bearer\s+/i, "");
65
+ : admission.source === "x-api-key" ? headers.get("x-api-key") : authorizationCredential(headers);
65
66
  const owner = admission.kind === "configured"
66
67
  ? JSON.stringify(["configured", admission.keyId])
67
68
  : JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]);
@@ -4,6 +4,7 @@ import type { OcxConfig } from "../types";
4
4
  import type { AudioClient } from "./audio-client";
5
5
  import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream";
6
6
  import type { RequestLogContext } from "./request-log";
7
+ import { authorizationCredential } from "../lib/authorization-scheme";
7
8
 
8
9
  export const DICTATION_SESSION_MAX_MS = 300_000;
9
10
  const DICTATION_FRAME_MAX_BYTES = 64 * 1024;
@@ -74,7 +75,7 @@ export async function resolveDictationSocket(
74
75
  return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account");
75
76
  }
76
77
  const headers = new Headers(relay.headers);
77
- const token = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
78
+ const token = authorizationCredential(headers);
78
79
  if (!token || /[\s,]/.test(token)) {
79
80
  relay.release();
80
81
  return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable");
@@ -24,6 +24,7 @@ import {
24
24
  } from "./auth-cors";
25
25
  import { codexAccountSelectionForTurn } from "./lifecycle";
26
26
  import type { RequestLogContext } from "./request-log";
27
+ import { authorizationCredential } from "../lib/authorization-scheme";
27
28
 
28
29
  export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
29
30
  export const LIVE_AUDIO_MODEL = "gpt-live-1-codex";
@@ -77,7 +78,7 @@ export async function resolveAudioUpstream(
77
78
  if (candidates.keyed?.providerName !== options.providerName) delete candidates.keyed;
78
79
  }
79
80
  const headers = new Headers(incoming);
80
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
81
+ const bearer = authorizationCredential(headers);
81
82
  if (bearer && isProxyAdmissionSecret(bearer, config)) {
82
83
  headers.delete("authorization");
83
84
  headers.delete("chatgpt-account-id");
@@ -47,6 +47,7 @@ import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compac
47
47
  import { vercelGatewayRoutingConfigError } from "../providers/vercel-gateway-routing";
48
48
  import { googleVertexLocationConfigError } from "../providers/google-vertex-location";
49
49
  import { xaiResponsesOptInState } from "../providers/xai-responses-opt-in";
50
+ import { authorizationCredential } from "../lib/authorization-scheme";
50
51
 
51
52
  let _corsOrigin = "http://localhost:10100";
52
53
  // Listener identity is process-local. Never infer this trust from forwarded headers: the
@@ -501,7 +502,7 @@ export function resolveContextPrincipal(req: Request, config: OcxConfig, admissi
501
502
  if (named) return named;
502
503
  if (admission?.kind !== "loopback") return undefined;
503
504
  const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
504
- const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
505
+ const bearer = authorizationCredential(req.headers);
505
506
  const apiKey = req.headers.get("x-api-key")?.trim();
506
507
  for (const [token, source] of [[dedicated, "dedicated"], [bearer, "bearer"], [apiKey, "x-api-key"]] as const) {
507
508
  if (!token) continue;
@@ -600,13 +601,13 @@ export class ForwardAdmissionCredentialError extends Error {
600
601
  }
601
602
 
602
603
  export function validateForwardAdmissionCredential(headers: Headers, config: OcxConfig): void {
603
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
604
+ const bearer = authorizationCredential(headers);
604
605
  if (bearer && isProxyAdmissionSecret(bearer, config)) throw new ForwardAdmissionCredentialError();
605
606
  }
606
607
 
607
608
  /** Whether Authorization carries a caller-owned native Codex credential safe to forward. */
608
609
  export function hasForwardableCodexBearer(headers: Headers, config: OcxConfig): boolean {
609
- const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
610
+ const bearer = authorizationCredential(headers);
610
611
  const accountId = headers.get("chatgpt-account-id")?.trim()
611
612
  || (bearer ? extractAccountId(undefined, bearer) : undefined);
612
613
  return !!bearer && !!accountId && !isProxyAdmissionSecret(bearer, config);
@@ -621,7 +622,7 @@ export function resolveApiAuth(req: Request, config: RequestPolicyView): DataPla
621
622
  if (!isApiAuthRequired(config)) return { kind: "loopback", source: "loopback" };
622
623
  const dedicated = req.headers.get("x-opencodex-api-key")?.trim();
623
624
  if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated", { linkIngress: config.linkIngress?.allowedKeyIds });
624
- const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
625
+ const bearer = authorizationCredential(req.headers);
625
626
  if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
626
627
  // Anthropic-SDK clients (Claude Code with ANTHROPIC_API_KEY) authenticate via x-api-key.
627
628
  const apiKey = req.headers.get("x-api-key")?.trim();
@@ -653,7 +654,7 @@ export function resolveResponsesApiAuth(req: Request, config: RequestPolicyView)
653
654
  // safe because the upstream credential is then SUBSTITUTED rather than forwarded -- see
654
655
  // materializeCodexUpstreamAuth. A bearer that is NOT our secret stays unadmitted here and
655
656
  // remains Codex Direct passthrough, so the two bearer domains still never mix.
656
- const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
657
+ const bearer = authorizationCredential(req.headers);
657
658
  if (bearer) return resolveDataPlaneAdmissionSecret(bearer, config, "bearer", { linkIngress: config.linkIngress?.allowedKeyIds });
658
659
  // `x-api-key` is deliberately NOT accepted on this transport.
659
660
  return null;
@@ -1194,6 +1195,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1194
1195
  desktopExecutor: "redacted",
1195
1196
  unsafeAllowNativeLocalExec: "editor",
1196
1197
  nativeLocalExec: "editor",
1198
+ messagePhaseInference: "editor",
1197
1199
  tlsProfile: "editor",
1198
1200
  } as const satisfies Record<keyof OcxProviderConfig, ProviderConfigFieldPolicy>;
1199
1201
 
@@ -32,6 +32,7 @@ import {
32
32
  startMemoryWatchdog,
33
33
  type MemoryWatchdog,
34
34
  } from "./memory-watchdog";
35
+ import { ensureQuotaPoolIdentity } from "../quota-pool/identity";
35
36
 
36
37
  type PolicyApply = (policy: StorageCleanupPolicy) => void;
37
38
 
@@ -204,6 +205,9 @@ export function acquireServerBackgroundLifecycle(
204
205
  if (cleanupInProgress) {
205
206
  throw new Error("server background lifecycle cleanup is still in progress");
206
207
  }
208
+ // The quota-pool identity (homeId and the accountRef key) is minted here, per server and off
209
+ // the request path, so GET /api/quota-pool only ever reads it.
210
+ try { ensureQuotaPoolIdentity(); } catch { /* the read reports quota_pool_identity_unavailable */ }
207
211
 
208
212
  const owner: LeaseOwner = {
209
213
  token: Symbol("server-background-lifecycle"),