@bitkyc08/opencodex 2.7.40 → 2.7.41

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 (110) hide show
  1. package/README.md +63 -12
  2. package/assets/claude-code-models.gif +0 -0
  3. package/gui/dist/assets/index-B2J4t3te.css +1 -0
  4. package/gui/dist/assets/index-BmvM6wRb.js +65 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -2
  7. package/src/adapters/google.ts +48 -9
  8. package/src/adapters/kiro-events.ts +15 -3
  9. package/src/adapters/kiro.ts +292 -28
  10. package/src/adapters/openai-chat.ts +63 -15
  11. package/src/adapters/openai-responses.ts +18 -0
  12. package/src/bridge.ts +76 -21
  13. package/src/chat/outbound.ts +66 -34
  14. package/src/claude/auth-detect.ts +229 -0
  15. package/src/claude/auth-mode-migration.ts +32 -0
  16. package/src/claude/auth-mode.ts +62 -0
  17. package/src/claude/desktop-3p-guard.ts +35 -0
  18. package/src/claude/desktop-3p.ts +121 -21
  19. package/src/claude/desktop-health.ts +26 -0
  20. package/src/claude/desktop-profile.ts +263 -0
  21. package/src/claude/inbound-debug.ts +4 -0
  22. package/src/claude/model-info.ts +9 -3
  23. package/src/cli/account-extended.ts +34 -0
  24. package/src/cli/account.ts +3 -1
  25. package/src/cli/claude-desktop.ts +152 -0
  26. package/src/cli/claude.ts +37 -3
  27. package/src/cli/doctor.ts +187 -6
  28. package/src/cli/help.ts +13 -1
  29. package/src/cli/index.ts +151 -57
  30. package/src/cli/status-oauth.ts +68 -0
  31. package/src/cli/status.ts +4 -0
  32. package/src/codex/account-lifecycle.ts +31 -0
  33. package/src/codex/auth-api.ts +133 -26
  34. package/src/codex/auth-collision.ts +55 -11
  35. package/src/codex/auth-context.ts +50 -6
  36. package/src/codex/catalog/provider-fetch.ts +6 -2
  37. package/src/codex/home.ts +61 -1
  38. package/src/codex/inject.ts +28 -66
  39. package/src/codex/injected-marker.ts +72 -0
  40. package/src/codex/journal.ts +39 -3
  41. package/src/codex/main-account-cache.ts +25 -0
  42. package/src/codex/model-cache.ts +20 -1
  43. package/src/codex/paths.ts +5 -0
  44. package/src/codex/routing.ts +138 -11
  45. package/src/codex/subagent-model-fallback.ts +455 -0
  46. package/src/codex/sync.ts +17 -0
  47. package/src/combos/failover.ts +10 -2
  48. package/src/combos/index.ts +1 -0
  49. package/src/combos/types.ts +9 -0
  50. package/src/config.ts +117 -0
  51. package/src/grok/inject.ts +339 -0
  52. package/src/grok/status.ts +88 -0
  53. package/src/grok/sync.ts +66 -0
  54. package/src/lib/destination-policy.ts +13 -0
  55. package/src/lib/errors.ts +59 -0
  56. package/src/lib/privacy.ts +9 -0
  57. package/src/lib/process-control.ts +57 -4
  58. package/src/oauth/health.ts +375 -0
  59. package/src/oauth/index.ts +69 -22
  60. package/src/oauth/kiro.ts +5 -3
  61. package/src/oauth/log.ts +48 -0
  62. package/src/oauth/store.ts +55 -6
  63. package/src/providers/alibaba-region-backup.ts +75 -0
  64. package/src/providers/alibaba-region-migration.ts +143 -0
  65. package/src/providers/alibaba-region-startup.ts +36 -0
  66. package/src/providers/api-keys.ts +5 -5
  67. package/src/providers/derive.ts +22 -1
  68. package/src/providers/free-directory.ts +181 -0
  69. package/src/providers/key-failover.ts +2 -2
  70. package/src/providers/kiro-models.ts +3 -2
  71. package/src/providers/openai-tiers.ts +1 -1
  72. package/src/providers/provider-id-rewrite.ts +150 -0
  73. package/src/providers/registry.ts +49 -2
  74. package/src/responses/parser.ts +49 -20
  75. package/src/responses/state.ts +156 -2
  76. package/src/router.ts +73 -5
  77. package/src/server/chat-completions.ts +78 -22
  78. package/src/server/claude-messages.ts +8 -0
  79. package/src/server/images.ts +2 -1
  80. package/src/server/index.ts +50 -30
  81. package/src/server/live.ts +2 -1
  82. package/src/server/management/agent-settings-routes.ts +337 -18
  83. package/src/server/management/api-access.ts +141 -0
  84. package/src/server/management/combo-routes.ts +3 -3
  85. package/src/server/management/config-routes.ts +4 -4
  86. package/src/server/management/logs-usage-routes.ts +64 -4
  87. package/src/server/management/model-routes.ts +114 -8
  88. package/src/server/management/oauth-account-routes.ts +31 -5
  89. package/src/server/management/provider-routes.ts +42 -11
  90. package/src/server/management/shared.ts +81 -2
  91. package/src/server/management/system-routes.ts +6 -1
  92. package/src/server/management-api.ts +24 -7
  93. package/src/server/port-reclaim.ts +261 -0
  94. package/src/server/request-log.ts +11 -5
  95. package/src/server/responses/collaboration.ts +11 -6
  96. package/src/server/responses/compact.ts +2 -1
  97. package/src/server/responses/core.ts +358 -155
  98. package/src/server/responses/passthrough-error.ts +53 -0
  99. package/src/server/search.ts +2 -1
  100. package/src/server/system-env.ts +23 -6
  101. package/src/server/windows-tcp-drop.ts +174 -0
  102. package/src/service.ts +43 -3
  103. package/src/types.ts +74 -6
  104. package/src/update/index.ts +30 -19
  105. package/src/update/job.ts +30 -15
  106. package/src/usage/log.ts +158 -2
  107. package/src/usage/summary.ts +8 -4
  108. package/src/web-search/loop.ts +4 -1
  109. package/gui/dist/assets/index-CMip1DzF.css +0 -1
  110. package/gui/dist/assets/index-cydcmbzC.js +0 -52
@@ -10,7 +10,7 @@ import {
10
10
  multiAgentGuidanceEnabled,
11
11
  providerBaseUrlConfigError,
12
12
  providerHeadersConfigError,
13
- saveConfig,
13
+ saveConfigPreservingClaudeCode,
14
14
  } from "../config";
15
15
  import {
16
16
  clearLoginState,
@@ -48,7 +48,8 @@ import {
48
48
  setDebugSettings,
49
49
  type DebugFlag,
50
50
  } from "../lib/debug-settings";
51
- import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
51
+ import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
52
+ import type { DesktopProfileModel } from "../claude/desktop-profile";
52
53
  import { drainAndShutdown } from "./lifecycle";
53
54
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
54
55
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
@@ -135,16 +136,32 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
135
136
 
136
137
  if (url.pathname === "/api/stop" && req.method === "POST") {
137
138
  const { restoreNativeCodex } = await import("../codex/inject");
138
- const { stopServiceIfInstalled } = await import("../service");
139
- stopServiceIfInstalled();
139
+ const { stopServiceIfInstalled, isServiceOwnershipError } = await import("../service");
140
+ try {
141
+ stopServiceIfInstalled();
142
+ } catch (err) {
143
+ if (isServiceOwnershipError(err)) {
144
+ // The installed service belongs to another CODEX_HOME/OPENCODEX_HOME: it would respawn
145
+ // this proxy immediately, and its shared config is not ours to tear down. Refuse the
146
+ // stop instead of half-performing it. 409, not 500 — the request is well-formed.
147
+ return jsonResponse({ success: false, message: err.message }, 409, req, config);
148
+ }
149
+ throw err;
150
+ }
140
151
  const restore = restoreNativeCodex();
152
+ // Both managed configs come down together on an explicit teardown. The daemon's own
153
+ // syncCleanup skips this when OCX_SERVICE is set (so a crash/respawn keeps the fence),
154
+ // which is exactly why an intentional stop has to do it here.
155
+ const { stripGrokConfig } = await import("../grok/inject");
156
+ const grok = stripGrokConfig();
141
157
  setTimeout(async () => {
142
158
  await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
143
159
  process.exit(0);
144
160
  }, 200);
161
+ const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
145
162
  return jsonResponse(restore.success
146
- ? { success: true, message: "Proxy stopping, native Codex restored." }
147
- : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.` });
163
+ ? { success: true, message: `Proxy stopping, native Codex restored.${grokNote}` }
164
+ : { success: false, message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}` });
148
165
  }
149
166
 
150
167
  if (url.pathname.startsWith("/api/codex-auth/")) {
@@ -156,4 +173,4 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
156
173
  }
157
174
 
158
175
 
159
- export { fetchAllModels } from "./management/shared";
176
+ export { buildClaudeDesktopState, fetchAllModels } from "./management/shared";
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Reclaim a listen port after stop/update so restart can stay on the configured
3
+ * port instead of hopping to an ephemeral one (Windows CLOSE_WAIT / leftover ocx).
4
+ *
5
+ * Killing is never the default: a process may be killed only when the caller
6
+ * supplies a non-empty explicit PID allowlist for a process it just stopped.
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { verifyPidIdentity } from "../config";
10
+ import { isProcessAlive, killProxy } from "../lib/process-control";
11
+ import { isPortAvailable, type WaitForPortOptions } from "./ports";
12
+ import { dropWindowsTcpRowsForLocalPort } from "./windows-tcp-drop";
13
+
14
+ export type ListenPidScan =
15
+ | { ok: true; pids: number[] }
16
+ | { ok: false; error?: string };
17
+
18
+ export type ReclaimListenPortOptions = WaitForPortOptions & {
19
+ /**
20
+ * When true AND `onlyKillPids` is a non-empty allowlist, those PIDs may be
21
+ * killed after revalidation. Default false — never kill without an allowlist.
22
+ */
23
+ killOcxHolders?: boolean;
24
+ /**
25
+ * Explicit PIDs the caller just stopped / hard-killed. An omitted or empty
26
+ * list means no process may be killed.
27
+ */
28
+ onlyKillPids?: number[];
29
+ /**
30
+ * On Windows, force-delete IPv4 TCP rows for this local port via SetTcpEntry.
31
+ * Default true on win32. Never kills foreign processes, never runs while a
32
+ * live foreign / protected ocx listener owns the port, and never runs when
33
+ * the listener scan failed.
34
+ */
35
+ dropTcpRows?: boolean;
36
+ /** How often to scan for listen PIDs / attempt TCB drop (ms). Default 500. */
37
+ scanIntervalMs?: number;
38
+ listListenPidsFn?: (port: number) => ListenPidScan | number[];
39
+ isAliveFn?: (pid: number) => boolean;
40
+ verifyOcxFn?: (pid: number) => number | null;
41
+ killFn?: (pid: number) => void;
42
+ dropTcpFn?: (port: number) => number | { dropped: number; skippedIpv6: number };
43
+ isAvailableFn?: (port: number, hostname?: string) => Promise<boolean>;
44
+ sleepMs?: (ms: number) => Promise<void>;
45
+ };
46
+
47
+ /**
48
+ * Parse `netstat -ano` (Windows) / `netstat -anlp` listen lines for a port.
49
+ * Exported for unit tests.
50
+ */
51
+ export function parseListenPidsFromNetstat(output: string, port: number): number[] {
52
+ const pids = new Set<number>();
53
+ const portSuffix = `:${port}`;
54
+ for (const rawLine of output.split(/\r?\n/)) {
55
+ const line = rawLine.trim();
56
+ if (!/^TCP\b/i.test(line) && !/^tcp\b/i.test(line)) continue;
57
+ const parts = line.split(/\s+/);
58
+ // Prefer the first address token that ends with :port (local), not a later foreign one.
59
+ const localIdx = parts.findIndex(part => part.endsWith(portSuffix) || part.endsWith(`]:${port}`));
60
+ if (localIdx < 0) continue;
61
+ const foreign = parts[localIdx + 1] ?? "";
62
+ // Locale-safe listen detection: English LISTEN*, or unbound foreign wildcard
63
+ // (German ABHÖREN still shows 0.0.0.0:0 / *:*).
64
+ const listenWord = /\bLISTEN/i.test(line);
65
+ const wildcardForeign = /^(0\.0\.0\.0|::|\*|\[::\]):0$/.test(foreign) || foreign === "*:*";
66
+ if (!listenWord && !wildcardForeign) continue;
67
+ const last = parts[parts.length - 1] ?? "";
68
+ const winPid = /^\d+$/.test(last) ? Number(last) : NaN;
69
+ const unixPid = /^(\d+)(?:\/\S*)?$/.exec(last);
70
+ const pid = Number.isSafeInteger(winPid) && winPid > 0
71
+ ? winPid
72
+ : unixPid
73
+ ? Number(unixPid[1])
74
+ : NaN;
75
+ if (Number.isSafeInteger(pid) && pid > 0) pids.add(pid);
76
+ }
77
+ return [...pids];
78
+ }
79
+
80
+ function normalizeListenPidScan(result: ListenPidScan | number[]): ListenPidScan {
81
+ if (Array.isArray(result)) return { ok: true, pids: result };
82
+ return result;
83
+ }
84
+
85
+ /** Prefer English netstat states; fall back to the UI-locale table. */
86
+ function readWindowsNetstatAno(): string {
87
+ const netstat = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\netstat.exe`;
88
+ const cmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
89
+ try {
90
+ // chcp 437 forces English LISTENING/ESTABLISHED labels on localized Windows.
91
+ return execFileSync(cmd, ["/d", "/c", `chcp 437>nul & "${netstat}" -ano -p tcp`], {
92
+ encoding: "utf-8",
93
+ stdio: ["ignore", "pipe", "ignore"],
94
+ timeout: 5000,
95
+ windowsHide: true,
96
+ });
97
+ } catch {
98
+ return execFileSync(netstat, ["-ano", "-p", "tcp"], {
99
+ encoding: "utf-8",
100
+ stdio: ["ignore", "pipe", "ignore"],
101
+ timeout: 4000,
102
+ windowsHide: true,
103
+ });
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Scan for PIDs currently LISTENing on `port`.
109
+ * Distinguishes probe failure (`ok: false`) from a successful empty result.
110
+ */
111
+ export function scanListenPids(port: number): ListenPidScan {
112
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
113
+ return { ok: false, error: "invalid port" };
114
+ }
115
+ try {
116
+ if (process.platform === "win32") {
117
+ return { ok: true, pids: parseListenPidsFromNetstat(readWindowsNetstatAno(), port) };
118
+ }
119
+ try {
120
+ const output = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
121
+ encoding: "utf-8",
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ timeout: 3000,
124
+ });
125
+ return {
126
+ ok: true,
127
+ pids: output
128
+ .split(/\r?\n/)
129
+ .map(line => Number(line.trim()))
130
+ .filter(pid => Number.isSafeInteger(pid) && pid > 0),
131
+ };
132
+ } catch (lsofErr) {
133
+ try {
134
+ const output = execFileSync("netstat", ["-anlp"], {
135
+ encoding: "utf-8",
136
+ stdio: ["ignore", "pipe", "ignore"],
137
+ timeout: 3000,
138
+ });
139
+ return { ok: true, pids: parseListenPidsFromNetstat(output, Math.trunc(port)) };
140
+ } catch (netstatErr) {
141
+ return {
142
+ ok: false,
143
+ error: `lsof/netstat unavailable: ${String(lsofErr)} / ${String(netstatErr)}`,
144
+ };
145
+ }
146
+ }
147
+ } catch (error) {
148
+ return { ok: false, error: String(error) };
149
+ }
150
+ }
151
+
152
+ /** Best-effort PIDs currently LISTENing on `port`. Empty on probe failure. */
153
+ export function listListenPids(port: number): number[] {
154
+ const scan = scanListenPids(port);
155
+ return scan.ok ? scan.pids : [];
156
+ }
157
+
158
+ /**
159
+ * Wait until `port` can bind.
160
+ * Never kills a process unless `killOcxHolders === true` and `onlyKillPids` is a
161
+ * non-empty allowlist of PIDs the caller itself just stopped — then revalidates
162
+ * immediately before each kill.
163
+ * Never kills foreign processes. Never drops TCP rows while a live foreign or
164
+ * protected ocx listener owns the port, or when the listener scan failed.
165
+ */
166
+ export async function reclaimListenPort(
167
+ port: number,
168
+ hostname = "127.0.0.1",
169
+ opts: ReclaimListenPortOptions = {},
170
+ ): Promise<boolean> {
171
+ const timeoutMs = opts.timeoutMs ?? 30_000;
172
+ const intervalMs = opts.intervalMs ?? 100;
173
+ const scanIntervalMs = opts.scanIntervalMs ?? 500;
174
+ const allowedKillPids = new Set(
175
+ (opts.onlyKillPids ?? []).filter(pid => Number.isSafeInteger(pid) && pid > 0),
176
+ );
177
+ const mayKill = opts.killOcxHolders === true && allowedKillPids.size > 0;
178
+ const dropTcpRows = opts.dropTcpRows ?? process.platform === "win32";
179
+ const listFn = opts.listListenPidsFn ?? scanListenPids;
180
+ const isAliveFn = opts.isAliveFn ?? isProcessAlive;
181
+ const verifyOcxFn = opts.verifyOcxFn ?? verifyPidIdentity;
182
+ const killFn = opts.killFn ?? killProxy;
183
+ const dropTcpFn = opts.dropTcpFn ?? dropWindowsTcpRowsForLocalPort;
184
+ const isAvailableFn = opts.isAvailableFn ?? isPortAvailable;
185
+ const sleep = opts.sleepMs ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
186
+
187
+ const deadline = Date.now() + timeoutMs;
188
+ let lastScan = 0;
189
+ const killed = new Set<number>();
190
+
191
+ for (;;) {
192
+ if (await isAvailableFn(port, hostname)) return true;
193
+ if (Date.now() >= deadline) return false;
194
+
195
+ if (Date.now() - lastScan >= scanIntervalMs) {
196
+ lastScan = Date.now();
197
+
198
+ const scan = normalizeListenPidScan(listFn(port));
199
+ if (!scan.ok) {
200
+ // Failed probe ≠ empty listeners: do not kill and do not reset TCP rows.
201
+ await sleep(intervalMs);
202
+ continue;
203
+ }
204
+
205
+ let foreignLive = false;
206
+ let protectedOcxListener = false;
207
+
208
+ for (const pid of scan.pids) {
209
+ if (pid === process.pid) continue;
210
+ if (!isAliveFn(pid)) continue; // Windows may still list a dead owner briefly
211
+ const isOcx = verifyOcxFn(pid) === pid;
212
+ if (!isOcx) {
213
+ foreignLive = true;
214
+ continue;
215
+ }
216
+ if (!mayKill || !allowedKillPids.has(pid)) {
217
+ // Healthy / intentional ocx proxy — never steal its port.
218
+ protectedOcxListener = true;
219
+ continue;
220
+ }
221
+ if (!killed.has(pid)) {
222
+ // Revalidate immediately before termination.
223
+ if (isAliveFn(pid) && verifyOcxFn(pid) === pid && allowedKillPids.has(pid)) {
224
+ try {
225
+ killFn(pid);
226
+ killed.add(pid);
227
+ } catch {
228
+ // Kill failed: keep waiting and never reset this listener's TCP rows.
229
+ protectedOcxListener = true;
230
+ }
231
+ } else {
232
+ // Revalidation failed while the allowlisted listener is still listed live.
233
+ protectedOcxListener = true;
234
+ }
235
+ }
236
+ // Only reset TCP rows after confirmed process death.
237
+ if (isAliveFn(pid)) protectedOcxListener = true;
238
+ }
239
+
240
+ if (foreignLive || protectedOcxListener) {
241
+ // Foreign app or an unprotected live ocx listener owns the port: never
242
+ // SetTcpEntry-reset their sockets, and fail reclaim once the deadline hits.
243
+ await sleep(intervalMs);
244
+ continue;
245
+ }
246
+
247
+ // After hard-kill, browsers often keep ESTABLISHED/CLOSE_WAIT to the dead listener.
248
+ // Reset those IPv4 TCBs (and ghost LISTEN rows) so the configured port can bind again —
249
+ // without killing the browser process. Only safe when no live foreign/protected listener remains.
250
+ if (dropTcpRows) {
251
+ try {
252
+ dropTcpFn(port);
253
+ } catch {
254
+ /* access denied / unsupported — keep waiting */
255
+ }
256
+ }
257
+ }
258
+
259
+ await sleep(intervalMs);
260
+ }
261
+ }
@@ -11,6 +11,7 @@ import type { OcxUsage } from "../types";
11
11
  import { redactSecretString } from "../lib/redact";
12
12
  import {
13
13
  appendUsageEntry,
14
+ isKnownUsageSurface,
14
15
  readRecentUsageEntries,
15
16
  usageForFinalLog,
16
17
  usageStatusForFinalLog,
@@ -33,7 +34,7 @@ export interface RequestLogContext {
33
34
  provider: string;
34
35
  /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */
35
36
  firstOutputMs?: number;
36
- surface?: "claude";
37
+ surface?: "claude" | "claude-desktop" | "grok";
37
38
  requestedModel?: string;
38
39
  /** Internal structural combo identity; omitted from RequestLogEntry/JSONL. */
39
40
  comboId?: string;
@@ -58,6 +59,11 @@ export interface RequestLogContext {
58
59
  /** Route adapter type ("cursor"/"kiro"/"anthropic"/…): drives estimated-usage detection
59
60
  * independent of the user-chosen provider NAME (devlog 130 B2). */
60
61
  providerAdapter?: string;
62
+ /** Set when the bridge reported raw adapter usage via onUsage: the bridged wire now always
63
+ * carries synthetic zero-default token-detail objects (strict-client normalization, see
64
+ * responsesUsage in src/bridge.ts), so SSE/JSON re-parsing must not overwrite the raw
65
+ * provenance — a synthetic cached_tokens:0 is NOT a measured cache read. */
66
+ usageFromBridge?: boolean;
61
67
  /** Secret-redacted upstream error reason (e.g. the granular Cursor "rate limit exceeded…"
62
68
  * message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
63
69
  * request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
@@ -76,7 +82,7 @@ export interface RequestLogEntry {
76
82
  provider: string;
77
83
  /** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */
78
84
  firstOutputMs?: number;
79
- surface?: "claude";
85
+ surface?: "claude" | "claude-desktop" | "grok";
80
86
  requestedModel?: string;
81
87
  requestedEffort?: string;
82
88
  requestedServiceTier?: string;
@@ -139,7 +145,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
139
145
  model: entry.model,
140
146
  provider: entry.provider,
141
147
  ...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
142
- ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
148
+ ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
143
149
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
144
150
  ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
145
151
  ...(entry.requestedServiceTier ? { requestedServiceTier: entry.requestedServiceTier } : {}),
@@ -215,7 +221,7 @@ export function addRequestLog(entry: RequestLogEntry) {
215
221
  timestamp: entry.timestamp,
216
222
  provider: entry.provider,
217
223
  model: entry.model,
218
- ...(entry.surface === "claude" ? { surface: entry.surface } : {}),
224
+ ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}),
219
225
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
220
226
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
221
227
  ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
@@ -338,7 +344,7 @@ export function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unk
338
344
  const serviceTier = (source as { service_tier?: unknown }).service_tier;
339
345
  if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier;
340
346
  const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
341
- if (usage) {
347
+ if (usage && !logCtx.usageFromBridge) {
342
348
  logCtx.usage = usage;
343
349
  if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
344
350
  }
@@ -61,6 +61,7 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f
61
61
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
62
62
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
63
63
  import { slugsEquivalent } from "../../providers/slug-codec";
64
+ import { subagentFallbackGuidanceText } from "../../codex/subagent-model-fallback";
64
65
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
65
66
  import { isUsageDebugEnabled } from "../../usage/debug";
66
67
  import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "../request-decompress";
@@ -160,6 +161,7 @@ export interface MultiAgentGuidanceOptions {
160
161
  injectionModel?: string;
161
162
  injectionEffort?: string;
162
163
  subagentModels?: string[];
164
+ subagentModelFallback?: string[];
163
165
  injectionPrompt?: string;
164
166
  }
165
167
 
@@ -194,6 +196,7 @@ export async function multiAgentGuidanceText(
194
196
  injectionModel,
195
197
  injectionEffort,
196
198
  subagentModels,
199
+ subagentModelFallback,
197
200
  injectionPrompt,
198
201
  } = options;
199
202
  const surface = collabSurface(parsed);
@@ -222,11 +225,12 @@ export async function multiAgentGuidanceText(
222
225
  .map(item => `${item.configured}:${item.reason}`)
223
226
  .join(", ")}`);
224
227
  }
225
- if (!injectionModel && roster === "") return null;
228
+ const fallbackGuidance = subagentFallbackGuidanceText({ subagentModelFallback } as OcxConfig);
229
+ if (!injectionModel && roster === "" && fallbackGuidance === "") return null;
226
230
  if (injectionPrompt) {
227
- return `<multi_agent_mode>${applyInjectionPlaceholders(injectionPrompt, injectionModel, injectionEffort, roster)}</multi_agent_mode>`;
231
+ return `<multi_agent_mode>${applyInjectionPlaceholders(injectionPrompt, injectionModel, injectionEffort, roster, fallbackGuidance)}</multi_agent_mode>`;
228
232
  }
229
- if (!preferred && roster === "") return null;
233
+ if (!preferred && roster === "" && fallbackGuidance === "") return null;
230
234
  let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, "
231
235
  + "use only models listed for this collaboration surface. "
232
236
  + "When setting either override, set fork_turns to \"none\" "
@@ -237,6 +241,7 @@ export async function multiAgentGuidanceText(
237
241
  + (injectionEffort ? `, reasoning_effort "${injectionEffort}"` : "")
238
242
  + " — use it unless the user names another.";
239
243
  }
244
+ text += fallbackGuidance;
240
245
  text += roster;
241
246
  if (text.length > V2_GUIDANCE_CHAR_BUDGET) {
242
247
  // Roster is the only unbounded part — drop it before breaking the budget.
@@ -256,11 +261,12 @@ export async function multiAgentGuidanceText(
256
261
 
257
262
  export const V2_GUIDANCE_CHAR_BUDGET = 700;
258
263
 
259
- export function applyInjectionPlaceholders(prompt: string, model?: string, effort?: string, roster?: string): string {
264
+ export function applyInjectionPlaceholders(prompt: string, model?: string, effort?: string, roster?: string, fallback?: string): string {
260
265
  return prompt
261
266
  .replaceAll("{{model}}", model ?? "")
262
267
  .replaceAll("{{effort}}", effort ?? "")
263
- .replaceAll("{{roster}}", roster ?? "");
268
+ .replaceAll("{{roster}}", roster ?? "")
269
+ .replaceAll("{{fallback}}", fallback ?? "");
264
270
  }
265
271
 
266
272
 
@@ -314,4 +320,3 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string):
314
320
  }
315
321
  }
316
322
  }
317
-
@@ -42,6 +42,7 @@ import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/
42
42
  import {
43
43
  applyCodexAuthContextToProvider,
44
44
  CodexAccountCooldownError,
45
+ cooldownErrorResponse,
45
46
  CodexAuthContextError,
46
47
  CodexDirectAuthenticationError,
47
48
  CodexPoolAuthenticationError,
@@ -231,7 +232,7 @@ export async function handleResponsesCompact(
231
232
  }
232
233
  } catch (err) {
233
234
  if (err instanceof CodexAccountCooldownError) {
234
- return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
235
+ return cooldownErrorResponse(err);
235
236
  }
236
237
  if (err instanceof CodexThreadAffinityExpiredError) {
237
238
  return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");