@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
@@ -1,4 +1,4 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
1
+ import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
4
  import type { OcxProviderContinuationState } from "../types";
@@ -14,6 +14,10 @@ const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
14
14
  * carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
15
15
  const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
16
16
  const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
17
+ const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
18
+ const STALE_TEMP_MAX_ENTRIES = 4_096;
19
+ const STALE_TEMP_MAX_CLEANUPS = 512;
20
+ const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
17
21
 
18
22
  interface StoredResponseState {
19
23
  createdAt: number;
@@ -88,6 +92,111 @@ function snapshotPath(): string {
88
92
  return join(getConfigDir(), "responses-state.json");
89
93
  }
90
94
 
95
+ export interface ResponseStateTempRecoveryResult {
96
+ matched: number;
97
+ removed: number;
98
+ failed: number;
99
+ bytesRemoved: number;
100
+ }
101
+
102
+ interface ResponseStateTempRecoveryIO {
103
+ now: () => number;
104
+ list: (dir: string) => Iterable<string>;
105
+ inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
106
+ isProcessAlive: (pid: number) => boolean;
107
+ unlink: (path: string) => void;
108
+ }
109
+
110
+ type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
111
+ maxEntries?: number;
112
+ maxCleanups?: number;
113
+ };
114
+
115
+ function processIsAlive(pid: number): boolean {
116
+ if (pid === process.pid) return true;
117
+ try {
118
+ process.kill(pid, 0);
119
+ return true;
120
+ } catch (error) {
121
+ // EPERM means the process exists but cannot be signalled. Unknown platform errors
122
+ // are also protected; cleanup should prefer a false negative over touching a live writer.
123
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
124
+ }
125
+ }
126
+
127
+ const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
128
+ now: Date.now,
129
+ list: function* list(dir) {
130
+ const handle = opendirSync(dir);
131
+ try {
132
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) yield entry.name;
133
+ } finally {
134
+ handle.closeSync();
135
+ }
136
+ },
137
+ inspect: path => {
138
+ const stat = lstatSync(path);
139
+ return { isFile: stat.isFile() && !stat.isSymbolicLink(), mtimeMs: stat.mtimeMs, size: stat.size };
140
+ },
141
+ isProcessAlive: processIsAlive,
142
+ unlink: unlinkSync,
143
+ };
144
+
145
+ /**
146
+ * Recover only abandoned response-state atomic-write files. The exact basename,
147
+ * regular-file check, age gate, and PID liveness check protect unrelated/active files.
148
+ * Cleanup is capped and best-effort because continuation state is only a cache. Removal
149
+ * deliberately uses unlink only: path-based truncation could follow a replacement symlink.
150
+ */
151
+ export function recoverStaleResponseStateTemps(
152
+ dir = getConfigDir(),
153
+ options: ResponseStateTempRecoveryOptions = {},
154
+ ): ResponseStateTempRecoveryResult {
155
+ const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options;
156
+ const io = { ...responseStateTempRecoveryIO, ...overrides };
157
+ const result: ResponseStateTempRecoveryResult = {
158
+ matched: 0,
159
+ removed: 0,
160
+ failed: 0,
161
+ bytesRemoved: 0,
162
+ };
163
+ let names: Iterable<string>;
164
+ try { names = io.list(dir); } catch { return result; }
165
+ let iterator: Iterator<string>;
166
+ try { iterator = names[Symbol.iterator](); } catch { return result; }
167
+ let scanned = 0;
168
+ for (;;) {
169
+ let next: IteratorResult<string>;
170
+ try { next = iterator.next(); } catch { return result; }
171
+ if (next.done) break;
172
+ const name = next.value;
173
+ scanned += 1;
174
+ if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
175
+ const match = RESPONSE_STATE_TEMP_NAME.exec(name);
176
+ if (!match) continue;
177
+ result.matched += 1;
178
+ const pid = Number(match[1]);
179
+ const sequence = Number(match[2]);
180
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(sequence) || sequence <= 0) continue;
181
+ const path = join(dir, name);
182
+ let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
183
+ try { file = io.inspect(path); } catch { continue; }
184
+ if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
185
+ if (pid === process.pid || io.isProcessAlive(pid)) continue;
186
+
187
+ try {
188
+ io.unlink(path);
189
+ result.removed += 1;
190
+ result.bytesRemoved += file.size;
191
+ } catch {
192
+ // Locked files remain for a later startup. Do not truncate by path: a same-user
193
+ // replacement could turn that fallback into an arbitrary symlink-target write.
194
+ result.failed += 1;
195
+ }
196
+ }
197
+ return result;
198
+ }
199
+
91
200
  /**
92
201
  * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
93
202
  * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
@@ -98,8 +207,13 @@ function snapshotPath(): string {
98
207
  function ensureLoaded(): void {
99
208
  if (loaded) return;
100
209
  loaded = true;
210
+ const path = snapshotPath();
211
+ try {
212
+ recoverStaleResponseStateTemps(dirname(path));
213
+ } catch {
214
+ /* best-effort cleanup only; snapshot loading must remain independent */
215
+ }
101
216
  try {
102
- const path = snapshotPath();
103
217
  if (!existsSync(path)) return;
104
218
  const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
105
219
  if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return;
@@ -241,6 +355,46 @@ export function previousResponseProviderState(responseId: string | undefined): O
241
355
  return providers ? structuredClone(providers) : undefined;
242
356
  }
243
357
 
358
+ export interface ResponseStateMetrics {
359
+ /** Number of retained continuation entries currently in RAM. */
360
+ count: number;
361
+ /** Sum of serialized item bytes across all entries (the running in-memory byte accounting).
362
+ * Because expanded previous_response_id chains share prefix item references, this is an UPPER
363
+ * bound on true heap (it re-counts shared history), never an under-count — safe as a
364
+ * memory-pressure signal. */
365
+ totalBytes: number;
366
+ /** Serialized item bytes of the single largest entry (a long chain's head, or an image-heavy turn). */
367
+ largestBytes: number;
368
+ /** Age of the oldest retained entry, in ms (0 when the store is empty). */
369
+ oldestAgeMs: number;
370
+ }
371
+
372
+ /**
373
+ * Observe-only snapshot of the in-RAM continuation store, surfaced via GET /api/system/memory.
374
+ * Additive and side-effect free — it does NOT lazy-load the disk snapshot, prune, or evict — so a
375
+ * diagnostics probe can sample it without perturbing request handling. `totalBytes` reads the
376
+ * running byte counter and `largestBytes` reads each entry's cached `sizeBytes`, so a probe never
377
+ * re-serializes the whole store (a large transient allocation that would fire exactly when memory
378
+ * is already under pressure). This is the seam for deciding whether RAM growth originates in this
379
+ * store (JS heap) or in the runtime allocator (native).
380
+ */
381
+ export function responseStateMetrics(): ResponseStateMetrics {
382
+ const at = now();
383
+ let largestBytes = 0;
384
+ let oldestCreatedAt = at;
385
+ for (const state of states.values()) {
386
+ const bytes = state.sizeBytes ?? 0;
387
+ if (bytes > largestBytes) largestBytes = bytes;
388
+ if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt;
389
+ }
390
+ return {
391
+ count: states.size,
392
+ totalBytes: storedResponseBytes,
393
+ largestBytes,
394
+ oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
395
+ };
396
+ }
397
+
244
398
  /**
245
399
  * Cache completed output and max_output_tokens partial output for previous_response_id replay.
246
400
  * Content-filtered incomplete and failed output are not authoritative replay history.
package/src/router.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types";
2
- import { COMBO_NAMESPACE, tryPickComboModel, type ComboPick } from "./combos";
2
+ import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos";
3
3
  import { hasOwnProvider, resolveEnvValue } from "./config";
4
4
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
5
+ import { redactSecretString, redactUrlForLog } from "./lib/redact";
5
6
  import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
6
7
  import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
7
8
  import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
@@ -117,6 +118,73 @@ function mergeStringArrayRecord(
117
118
  return out;
118
119
  }
119
120
 
121
+ /** Same endpoint modulo surrounding space and trailing slashes — matches `matchBaseUrlChoice`. */
122
+ function isSameEndpoint(a: string, b: string): boolean {
123
+ return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, "");
124
+ }
125
+
126
+ /**
127
+ * Origin of a user-configured URL, with the path withheld.
128
+ *
129
+ * A configured `baseUrl` is user-controlled and its path may itself be the credential — an
130
+ * account-scoped route token such as `https://proxy.example/v1/8fK2mP7qR4nV6x` is opaque and
131
+ * high-entropy, so it matches none of the prefix patterns in `redactSecretString`. Pattern
132
+ * redaction cannot be trusted for this value, so no path segment is logged at all. `URL.origin`
133
+ * also excludes userinfo, query and fragment.
134
+ *
135
+ * `…/…` marks that a path was present without revealing it, so a reader can tell an origin-only
136
+ * config apart from one whose path was dropped.
137
+ */
138
+ function configuredOriginForLog(url: string): string {
139
+ try {
140
+ const parsed = new URL(url.trim());
141
+ // "null" is what URL.origin yields for non-special schemes; treat it as unusable.
142
+ if (!parsed.origin || parsed.origin === "null") return "(unloggable URL)";
143
+ const hasPath = parsed.pathname !== "" && parsed.pathname !== "/";
144
+ return hasPath ? `${parsed.origin}/…` : parsed.origin;
145
+ } catch {
146
+ return "(unparseable URL)";
147
+ }
148
+ }
149
+
150
+ // `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple.
151
+ // Keyed by the URLs too: editing config.json to a different wrong value warns again.
152
+ const discardedBaseUrlWarnings = new Set<string>();
153
+
154
+ /**
155
+ * A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved
156
+ * `baseUrl`. Dropping it silently is a footgun: requests go to an endpoint the user never
157
+ * configured, and a wrong-region or wrong-account URL then surfaces only as a 401 with nothing
158
+ * pointing back at the discarded setting.
159
+ *
160
+ * Warns rather than throws. The effective route is exactly what it was before, so a hard error
161
+ * here would break configs that route fine today (a stale `baseUrl` left over from an earlier
162
+ * provider is harmless whenever it names the same endpoint the registry pins).
163
+ */
164
+ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void {
165
+ if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return;
166
+ // Asymmetric on purpose. Past the guard above, `effectiveBaseUrl` is necessarily
167
+ // `registryEntry.baseUrl`: the caller passes the resolved URL, and whenever that resolution
168
+ // kept the user's value the two are equal and we have already returned. So the effective side
169
+ // is a constant from this repo's registry and safe to print in full — it is also the useful
170
+ // half, naming the endpoint requests will actually use. The configured side is untrusted.
171
+ const discarded = configuredOriginForLog(userBaseUrl);
172
+ const effective = redactSecretString(redactUrlForLog(effectiveBaseUrl));
173
+ // Key off the logged forms: no raw credential is retained for the process lifetime, and
174
+ // rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch.
175
+ // Coarser than the raw URLs — two bad paths on one host warn once, which is the right grain.
176
+ const key = `${providerName} | ${discarded} | ${effective}`;
177
+ if (discardedBaseUrlWarnings.has(key)) return;
178
+ discardedBaseUrlWarnings.add(key);
179
+ console.warn(
180
+ // Routing is what this warning speaks for: an adapter may adjust the endpoint again
181
+ // downstream (kiro re-derives the region), so do not promise where the request lands.
182
+ `⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored`
183
+ + ` because this provider's endpoint is fixed at ${effective}. A URL saved for a different`
184
+ + ` account or region is a common cause of 401s here — drop it, or use the provider whose endpoint matches.`,
185
+ );
186
+ }
187
+
120
188
  function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
121
189
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
122
190
  if (!registryEntry) {
@@ -150,6 +218,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
150
218
  const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
151
219
  const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
152
220
  const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
221
+ const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
153
222
  const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
154
223
  const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
155
224
  const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
@@ -162,6 +231,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
162
231
  const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
163
232
  ? userBaseUrl
164
233
  : registryEntry.baseUrl;
234
+ if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl);
165
235
  assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
166
236
 
167
237
  return {
@@ -203,6 +273,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
203
273
  ...(noPenaltyModels ? { noPenaltyModels } : {}),
204
274
  ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
205
275
  ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
276
+ ...(reasoningSplitModels ? { reasoningSplitModels } : {}),
206
277
  ...(thinkingToggleModels ? { thinkingToggleModels } : {}),
207
278
  ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
208
279
  };
@@ -235,10 +306,7 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId:
235
306
  }
236
307
 
237
308
  function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
238
- const preservePhysicalComboProvider =
239
- hasOwnProvider(config.providers, COMBO_NAMESPACE)
240
- && Object.keys(config.combos ?? {}).length === 0;
241
- if (!bypassCombos && !preservePhysicalComboProvider) {
309
+ if (!bypassCombos && !preservesPhysicalComboProvider(config)) {
242
310
  const combo = tryPickComboModel(config, modelId);
243
311
  if (combo) {
244
312
  const concrete = `${combo.target.provider}/${combo.target.model}`;
@@ -14,6 +14,8 @@ import {
14
14
  responsesJsonToChatCompletion,
15
15
  responsesSseToChatCompletionsSse,
16
16
  } from "../chat/outbound";
17
+ import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
18
+ import { redactSecretString } from "../lib/redact";
17
19
  import { estimateTokens } from "../lib/token-estimate";
18
20
  import { routeModel } from "../router";
19
21
  import { resolveWireProtocolOverride } from "./adapter-resolve";
@@ -61,6 +63,10 @@ export async function handleChatCompletions(
61
63
 
62
64
  const requestedModel = (chatBody as Rec).model as string;
63
65
  const stream = internalBody.stream === true;
66
+ // Best-effort Grok attribution: the managed fence stamps this header on every model
67
+ // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage
68
+ // bucketing only — never an auth or billing signal.
69
+ if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok";
64
70
  // Routed adapters only support streamed turns; always stream internally and fold
65
71
  // for non-streaming clients.
66
72
  internalBody.stream = true;
@@ -146,43 +152,78 @@ export async function handleChatCompletions(
146
152
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
147
153
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
148
154
  });
149
- const response = logIds
150
- ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx)
151
- : upstream;
152
155
 
153
- if (!response.ok) {
154
- let message = `upstream error (${response.status})`;
156
+ // Rewrite non-2xx before deferred logging so /api/logs records the client-facing status
157
+ // (e.g. cyber_policy remapped from a passthrough 5xx to HTTP 400).
158
+ if (!upstream.ok) {
159
+ let message = `upstream error (${upstream.status})`;
160
+ let upstreamCode: string | null | undefined;
161
+ let upstreamType: string | undefined;
155
162
  try {
156
- const text = await response.text();
163
+ const text = await upstream.text();
157
164
  try {
158
- const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string };
159
- const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined;
165
+ const parsed = JSON.parse(text) as {
166
+ error?: { message?: string; type?: string; code?: string | null } | string;
167
+ message?: string;
168
+ };
169
+ const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error : undefined;
160
170
  const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
161
- message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message);
171
+ const rawFallback = text
172
+ ? `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`
173
+ : message;
174
+ message = nested?.message || flat || rawFallback;
175
+ if (nested) {
176
+ if (typeof nested.type === "string") upstreamType = nested.type;
177
+ if (nested.code === null || typeof nested.code === "string") upstreamCode = nested.code;
178
+ }
162
179
  } catch {
163
- if (text) message = `upstream error (${response.status}): ${text.slice(0, 400)}`;
180
+ if (text) message = `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`;
164
181
  }
165
182
  } catch { /* keep fallback */ }
166
- const retryAfter = response.headers.get("retry-after");
167
- return new Response(JSON.stringify({
183
+ const retryAfter = upstream.headers.get("retry-after");
184
+ const classified = classifyError(
185
+ upstream.status,
186
+ upstreamType
187
+ ?? (upstream.status === 401 ? "authentication_error"
188
+ : upstream.status === 429 ? "rate_limit_error"
189
+ : upstream.status >= 500 ? "server_error"
190
+ : "invalid_request_error"),
191
+ message,
192
+ );
193
+ if (isCyberPolicyCode(upstreamCode)) {
194
+ classified.code = CYBER_POLICY_ERROR_CODE;
195
+ classified.type = "invalid_request_error";
196
+ } else if (upstreamCode === "model_not_found") {
197
+ // Structured model_not_found must win over classifyError's generic remaps.
198
+ classified.code = "model_not_found";
199
+ classified.type = "invalid_request_error";
200
+ } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
201
+ classified.code = upstreamCode;
202
+ }
203
+ const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status;
204
+ const rewritten = new Response(JSON.stringify({
168
205
  error: {
169
- message,
170
- type: response.status === 401 ? "authentication_error"
171
- : response.status === 429 ? "rate_limit_error"
172
- : response.status >= 500 ? "server_error"
173
- : "invalid_request_error",
206
+ message: classified.message,
207
+ type: classified.type,
174
208
  param: null,
175
- code: null,
209
+ code: classified.code,
176
210
  },
177
211
  }), {
178
- status: response.status,
212
+ status,
179
213
  headers: {
180
214
  "Content-Type": "application/json",
181
215
  ...(retryAfter ? { "Retry-After": retryAfter } : {}),
182
216
  },
183
217
  });
218
+ return logIds
219
+ ? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx)
220
+ : rewritten;
184
221
  }
185
222
 
223
+ const response = logIds
224
+ ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx)
225
+ : upstream;
226
+
186
227
  const contentType = response.headers.get("content-type") ?? "";
187
228
  if (contentType.includes("text/event-stream") && response.body) {
188
229
  const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel);
@@ -206,7 +247,7 @@ export async function handleChatCompletions(
206
247
  });
207
248
  } catch (err) {
208
249
  if (isChatCompletionsStreamError(err)) {
209
- return chatCompletionsErrorResponse(err.status, err.message, err.type);
250
+ return chatCompletionsErrorResponse(err.status, err.message, err.type, err.code);
210
251
  }
211
252
  return chatCompletionsErrorResponse(
212
253
  502,
@@ -225,8 +266,23 @@ export async function handleChatCompletions(
225
266
  }
226
267
  const status = (json as Rec)?.status;
227
268
  if (status === "failed") {
228
- const error = (json as { error?: { message?: string } }).error;
229
- return chatCompletionsErrorResponse(502, error?.message ?? "upstream request failed", "server_error");
269
+ const error = (json as { error?: { message?: string; type?: string; code?: string | null } }).error;
270
+ const message = error?.message ?? "upstream request failed";
271
+ const classified = classifyError(502, error?.type ?? "server_error", message);
272
+ if (isCyberPolicyCode(error?.code)) {
273
+ classified.code = CYBER_POLICY_ERROR_CODE;
274
+ classified.type = "invalid_request_error";
275
+ } else if (error?.code === "model_not_found") {
276
+ // Same deliberate preserve as the non-OK path: structured code beats generic classify.
277
+ classified.code = "model_not_found";
278
+ classified.type = "invalid_request_error";
279
+ }
280
+ return chatCompletionsErrorResponse(
281
+ isCyberPolicyCode(classified.code) ? 400 : 502,
282
+ message,
283
+ classified.type,
284
+ classified.code,
285
+ );
230
286
  }
231
287
  const completion = responsesJsonToChatCompletion(json, requestedModel);
232
288
  if (!stream) {
@@ -10,6 +10,8 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
10
  import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
11
11
  import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
12
12
  import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
13
+ import { resolveDesktop3pAlias } from "../claude/desktop-3p";
14
+ import { recordDesktopRequest } from "../claude/desktop-health";
13
15
  import { stripOneMillionMarker } from "../claude/context-windows";
14
16
  import { captureClaudeInbound } from "../claude/inbound-debug";
15
17
  import { isTransientUpstreamStatus } from "../lib/upstream-retry";
@@ -547,6 +549,12 @@ export async function handleClaudeMessages(
547
549
  : undefined,
548
550
  req.headers.get("anthropic-beta") ?? undefined,
549
551
  );
552
+ // Client surface discrimination: Desktop 3P aliases resolve through the
553
+ // desktop registry; Code uses readable aliases or direct model names.
554
+ if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)) {
555
+ logCtx.surface = "claude-desktop";
556
+ recordDesktopRequest();
557
+ }
550
558
  if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
551
559
  return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
552
560
  }
@@ -14,6 +14,7 @@
14
14
  import { formatErrorResponse } from "../bridge";
15
15
  import {
16
16
  CodexAccountCooldownError,
17
+ cooldownErrorResponse,
17
18
  CodexAuthContextError,
18
19
  CodexPoolAuthenticationError,
19
20
  CodexThreadAffinityExpiredError,
@@ -84,7 +85,7 @@ export async function handleImages(
84
85
  if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config);
85
86
  } catch (err) {
86
87
  if (err instanceof CodexAccountCooldownError) {
87
- forwardAuthError = formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
88
+ forwardAuthError = cooldownErrorResponse(err);
88
89
  } else if (err instanceof CodexThreadAffinityExpiredError) {
89
90
  forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
90
91
  } else if (err instanceof CodexAuthContextError) {