@bitkyc08/opencodex 2.7.35 → 2.7.36

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 (80) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +4 -2
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/bin/ocx.mjs +52 -0
  7. package/gui/dist/assets/index-BpX-hoSd.css +1 -0
  8. package/gui/dist/assets/index-ZmFopEYw.js +52 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/src/adapters/cursor/cursor-errors.ts +38 -1
  12. package/src/adapters/cursor/discovery.ts +1 -0
  13. package/src/adapters/cursor/effort-map.ts +1 -0
  14. package/src/adapters/cursor/live-models.ts +22 -5
  15. package/src/adapters/cursor/live-transport.ts +82 -7
  16. package/src/adapters/cursor/transport.ts +2 -0
  17. package/src/adapters/cursor.ts +5 -2
  18. package/src/adapters/openai-responses.ts +64 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +10 -0
  21. package/src/cli/index.ts +88 -9
  22. package/src/cli/internal-dispatch.ts +20 -0
  23. package/src/cli/status.ts +15 -4
  24. package/src/cli/tray-proxy.ts +52 -0
  25. package/src/codex/auth-api.ts +46 -5
  26. package/src/codex/autostart-health.ts +149 -0
  27. package/src/codex/catalog/aggregation.ts +268 -0
  28. package/src/codex/catalog/bundled.ts +188 -0
  29. package/src/codex/catalog/effort.ts +263 -0
  30. package/src/codex/catalog/metadata.ts +176 -0
  31. package/src/codex/catalog/parsing.ts +399 -0
  32. package/src/codex/catalog/provider-fetch.ts +609 -0
  33. package/src/codex/catalog/sync.ts +540 -0
  34. package/src/codex/catalog.ts +11 -2426
  35. package/src/codex/inject.ts +165 -3
  36. package/src/codex/shim.ts +141 -8
  37. package/src/codex/sync.ts +17 -2
  38. package/src/config.ts +23 -0
  39. package/src/lib/errors.ts +11 -0
  40. package/src/providers/antigravity-models.ts +33 -0
  41. package/src/providers/kiro-models.ts +2 -0
  42. package/src/providers/registry.ts +2 -2
  43. package/src/responses/state.ts +69 -6
  44. package/src/server/auth-cors.ts +3 -0
  45. package/src/server/management/agent-settings-routes.ts +536 -0
  46. package/src/server/management/combo-routes.ts +210 -0
  47. package/src/server/management/config-routes.ts +302 -0
  48. package/src/server/management/context.ts +21 -0
  49. package/src/server/management/logs-usage-routes.ts +176 -0
  50. package/src/server/management/model-routes.ts +253 -0
  51. package/src/server/management/oauth-account-routes.ts +301 -0
  52. package/src/server/management/provider-routes.ts +408 -0
  53. package/src/server/management/shared.ts +186 -0
  54. package/src/server/management-api.ts +23 -1806
  55. package/src/server/responses/collaboration.ts +300 -0
  56. package/src/server/responses/compact.ts +342 -0
  57. package/src/server/responses/core.ts +1498 -0
  58. package/src/server/responses/encrypted-payload.ts +231 -0
  59. package/src/server/responses/fetch-helpers.ts +157 -0
  60. package/src/server/responses.ts +9 -2172
  61. package/src/server/startup-action-control.ts +41 -0
  62. package/src/server/startup-health-cache.ts +100 -0
  63. package/src/server/windows-tray-control.ts +41 -0
  64. package/src/service.ts +171 -19
  65. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  66. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  67. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  68. package/src/tray/assets/opencodex-tray.png +0 -0
  69. package/src/tray/windows-tray.ps1 +290 -0
  70. package/src/tray/windows.ts +628 -0
  71. package/src/types.ts +5 -0
  72. package/src/update/index.ts +43 -0
  73. package/src/update/job.ts +46 -0
  74. package/src/update/tray-update-plan.d.mts +18 -0
  75. package/src/update/tray-update-plan.mjs +38 -0
  76. package/src/usage/cost.ts +0 -0
  77. package/src/usage/expected-prices.ts +9 -2
  78. package/src/usage/summary.ts +42 -7
  79. package/gui/dist/assets/index-BunUANVE.js +0 -52
  80. package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
@@ -1,13 +1,24 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { atomicWriteFile, loadConfig, websocketsEnabled } from "../config";
3
- import { markJournalInjectedState, restoreJournalState, writeJournal } from "./journal";
3
+ import { markJournalInjectedState, removeJournal, restoreJournalState, writeJournal } from "./journal";
4
4
  import { restoreCodexCatalog } from "./catalog";
5
5
  import { migrateHistoryToOpenai, syncCodexHistoryProvider } from "./history-provider";
6
6
  import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, DEFAULT_CATALOG_PATH, parseTomlString, readRootTomlString, resolveCodexConfigPath, tomlString } from "./paths";
7
+ import { resolveEffectiveProjectModelProvider } from "./project-config-warnings";
7
8
  import type { OcxConfig } from "../types";
8
9
 
9
10
  const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
10
11
 
12
+ export function externalCodexModelProvider(content: string): string | null {
13
+ const provider = resolveEffectiveProjectModelProvider(content).provider;
14
+ return provider && provider !== "openai" && provider !== "opencodex" ? provider : null;
15
+ }
16
+
17
+ export function currentExternalCodexModelProvider(): string | null {
18
+ if (!existsSync(CODEX_CONFIG_PATH)) return null;
19
+ return externalCodexModelProvider(readFileSync(CODEX_CONFIG_PATH, "utf8"));
20
+ }
21
+
11
22
  /**
12
23
  * Detect the file's dominant line ending. Every transform in this module is LF-pure
13
24
  * (split("\n") + hard "\n" joins), so CRLF configs (Windows-edited config.toml) are
@@ -151,7 +162,7 @@ export function stripInjectedOpenaiBaseUrl(content: string): string {
151
162
  return lines.filter((_, i) => !drop.has(i)).join("\n");
152
163
  }
153
164
 
154
- function hasInjectedOpenaiBaseUrl(content: string): boolean {
165
+ export function hasInjectedOpenaiBaseUrl(content: string): boolean {
155
166
  const lines = content.split("\n");
156
167
  const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
157
168
  const rootEnd = firstTable === -1 ? lines.length : firstTable;
@@ -161,6 +172,137 @@ function hasInjectedOpenaiBaseUrl(content: string): boolean {
161
172
  return false;
162
173
  }
163
174
 
175
+ export type CodexRoutingKind = "native" | "opencodex-local" | "custom-local" | "custom-remote" | "unknown";
176
+
177
+ function tomlStringPattern(key: string): RegExp {
178
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
179
+ const keyToken = `(?:${escaped}|\"${escaped}\"|'${escaped}')`;
180
+ return new RegExp(`^\\s*${keyToken}\\s*=\\s*[\"']([^\"']+)[\"']\\s*(?:#.*)?$`);
181
+ }
182
+
183
+ function rootTomlString(content: string, key: string): string | null {
184
+ const lines = content.split("\n");
185
+ const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
186
+ const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
187
+ const pattern = tomlStringPattern(key);
188
+ for (const line of rootLines) {
189
+ const match = pattern.exec(line);
190
+ if (match?.[1]) return match[1].trim();
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function providerTableStart(lines: string[], provider: string): number {
196
+ const escapedProvider = provider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
197
+ const providerToken = `(?:${escapedProvider}|\"${escapedProvider}\"|'${escapedProvider}')`;
198
+ const header = new RegExp(`^\\s*\\[\\s*(?:model_providers|\"model_providers\"|'model_providers')\\s*\\.\\s*${providerToken}\\s*\\]\\s*(?:#.*)?$`);
199
+ return lines.findIndex(line => header.test(line));
200
+ }
201
+
202
+ function providerTableString(content: string, provider: string, key: string): string | null {
203
+ const lines = content.split("\n");
204
+ const start = providerTableStart(lines, provider);
205
+ if (start === -1) return null;
206
+ const pattern = tomlStringPattern(key);
207
+ for (let index = start + 1; index < lines.length && !/^\s*\[/.test(lines[index]); index += 1) {
208
+ const match = pattern.exec(lines[index]);
209
+ if (match?.[1]) return match[1].trim();
210
+ }
211
+ return null;
212
+ }
213
+
214
+ type RoutingEndpointKind = "local" | "remote" | "unknown";
215
+
216
+ function ipv4Octets(hostname: string): number[] | null {
217
+ const dotted = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
218
+ if (dotted) {
219
+ const octets = dotted.slice(1).map(Number);
220
+ return octets.some(octet => octet > 255) ? null : octets;
221
+ }
222
+ const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(hostname);
223
+ if (!mapped) return null;
224
+ const high = Number.parseInt(mapped[1], 16);
225
+ const low = Number.parseInt(mapped[2], 16);
226
+ return [high >>> 8, high & 0xff, low >>> 8, low & 0xff];
227
+ }
228
+
229
+ function classifyRoutingEndpoint(value: string): RoutingEndpointKind {
230
+ try {
231
+ const url = new URL(value);
232
+ if (url.protocol !== "http:" && url.protocol !== "https:") return "unknown";
233
+ const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
234
+ if (!hostname) return "unknown";
235
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return "local";
236
+ if (hostname === "::" || hostname === "::1" || hostname === "0.0.0.0") return "local";
237
+ const octets = ipv4Octets(hostname);
238
+ if (octets) {
239
+ if (octets.every(octet => octet === 0)) return "local";
240
+ if (octets[0] === 127) return "local";
241
+ return "remote";
242
+ }
243
+ if (/^::ffff:/i.test(hostname)) return "unknown";
244
+ return "remote";
245
+ } catch {
246
+ return "unknown";
247
+ }
248
+ }
249
+
250
+ /** Classify actual routing dependency separately from opencodex ownership. */
251
+ export function classifyCodexRouting(content: string): CodexRoutingKind {
252
+ const rootBaseUrl = rootTomlString(content, "openai_base_url");
253
+ if (rootBaseUrl) {
254
+ const endpoint = classifyRoutingEndpoint(rootBaseUrl);
255
+ if (endpoint === "unknown") return "unknown";
256
+ if (hasInjectedOpenaiBaseUrl(content)) return "opencodex-local";
257
+ return endpoint === "local" ? "custom-local" : "custom-remote";
258
+ }
259
+ const rootProvider = rootTomlString(content, "model_provider");
260
+ if (rootProvider) {
261
+ const providerTableExists = providerTableStart(content.split("\n"), rootProvider) !== -1;
262
+ const providerBaseUrl = providerTableString(content, rootProvider, "base_url");
263
+ if (providerBaseUrl) {
264
+ const endpoint = classifyRoutingEndpoint(providerBaseUrl);
265
+ if (endpoint === "unknown") return "unknown";
266
+ if (rootProvider === "opencodex") return "opencodex-local";
267
+ return endpoint === "local" ? "custom-local" : "custom-remote";
268
+ }
269
+ if (rootProvider === "opencodex" || providerTableExists || rootProvider !== "openai") return "unknown";
270
+ }
271
+ return "native";
272
+ }
273
+
274
+ /**
275
+ * True when the active Codex config is owned by opencodex routing. Covers the
276
+ * loopback Design B root override and the legacy/non-loopback provider table.
277
+ * A user-owned `openai_base_url` is intentionally not classified as injected.
278
+ */
279
+ export function hasInjectedCodexRouting(content: string): boolean {
280
+ if (hasInjectedOpenaiBaseUrl(content)) return true;
281
+ return rootTomlString(content, "model_provider") === "opencodex"
282
+ && providerTableString(content, "opencodex", "base_url") !== null;
283
+ }
284
+
285
+ /** Read-only probe used by status, doctor, and the dashboard. */
286
+ export function isCodexRoutingInjected(): boolean {
287
+ const path = CODEX_CONFIG_PATH;
288
+ if (!existsSync(path)) return false;
289
+ try {
290
+ return hasInjectedCodexRouting(readFileSync(path, "utf8"));
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
295
+
296
+ export function getCodexRoutingKind(): CodexRoutingKind {
297
+ const path = CODEX_CONFIG_PATH;
298
+ if (!existsSync(path)) return "native";
299
+ try {
300
+ return classifyCodexRouting(readFileSync(path, "utf8"));
301
+ } catch {
302
+ return "unknown";
303
+ }
304
+ }
305
+
164
306
  /**
165
307
  * Strip every existing `model_provider` line that we must not duplicate: any line set to
166
308
  * "opencodex" (wherever it sits — including a previously mis-nested one under a table), plus any
@@ -365,8 +507,23 @@ export async function injectCodexConfig(port: number, config?: OcxConfig, option
365
507
  return { success: false, message: `Codex config not found at ${CODEX_CONFIG_PATH}. Is Codex installed?` };
366
508
  }
367
509
 
368
- writeJournal();
369
510
  const rawContent = readFileSync(CODEX_CONFIG_PATH, "utf-8");
511
+ const activeProvider = externalCodexModelProvider(rawContent);
512
+ if (activeProvider) {
513
+ // A launcher may have journaled before the provider manager took ownership. Never let shutdown
514
+ // replay that stale snapshot over externally managed config.
515
+ removeJournal();
516
+ return {
517
+ success: true,
518
+ message: `⚠️ Codex routing NOT injected: config.toml selects the external model_provider ${tomlString(activeProvider)}.\n` +
519
+ ` OpenCodex preserves external provider configuration so existing ${tomlString(activeProvider)} session history stays visible.\n` +
520
+ ` Configure that provider for Responses passthrough at http://${providerBaseHost(config?.hostname)}:${port}/v1` +
521
+ `${shouldInjectApiAuthHeader(config) ? ` with x-opencodex-api-key from OPENCODEX_API_AUTH_TOKEN` : ""}.\n` +
522
+ ` For direct injection, switch to the built-in openai provider, remove any user-owned root openai_base_url, and rerun 'ocx start'.`,
523
+ };
524
+ }
525
+
526
+ writeJournal();
370
527
  // EOL boundary: transforms below are LF-pure; preserve the file's dominant ending on write.
371
528
  const eol = dominantEol(rawContent);
372
529
  let content = applyEol(rawContent, "\n");
@@ -543,6 +700,11 @@ export function removeCodexConfig(options: { preserveProfile?: boolean } = {}):
543
700
  * handler, and `ocx restore`. Idempotent + atomic.
544
701
  */
545
702
  export function restoreNativeCodex(): { success: boolean; message: string } {
703
+ const activeProvider = currentExternalCodexModelProvider();
704
+ if (activeProvider) {
705
+ removeJournal();
706
+ return { success: true, message: `External Codex provider ${tomlString(activeProvider)} preserved; no native restore was needed.` };
707
+ }
546
708
  const journal = restoreJournalState();
547
709
  const cfg = journal.configRestored
548
710
  ? { success: true, message: "Codex config restored from opencodex journal." }
package/src/codex/shim.ts CHANGED
@@ -34,6 +34,23 @@ const CODEX_INTERNAL_COMMANDS = [
34
34
  "update",
35
35
  ];
36
36
 
37
+ // Codex accepts global options before a subcommand. The shim must skip the value belonging to
38
+ // these options before it decides which first positional token is the real subcommand. Keep this
39
+ // list aligned with `codex --help`; `--option=value` and attached short forms stay one token.
40
+ const CODEX_GLOBAL_OPTIONS_WITH_VALUE = [
41
+ "-c", "--config",
42
+ "--enable", "--disable",
43
+ "--remote", "--remote-auth-token-env",
44
+ "-i", "--image",
45
+ "-m", "--model",
46
+ "--local-provider",
47
+ "-p", "--profile",
48
+ "-s", "--sandbox",
49
+ "-C", "--cd",
50
+ "--add-dir",
51
+ "-a", "--ask-for-approval",
52
+ ];
53
+
37
54
  interface ShimState {
38
55
  platform: NodeJS.Platform;
39
56
  wrapperPath: string;
@@ -71,6 +88,17 @@ function isShim(path: string): boolean {
71
88
  }
72
89
  }
73
90
 
91
+ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
92
+ try {
93
+ const content = readFileSync(path, "utf8");
94
+ if (content.length < 180 || !content.includes(SHIM_MARKER) || !content.includes("ensure")) return false;
95
+ if (platform !== "win32" && (lstatSync(path).mode & 0o111) === 0) return false;
96
+ return true;
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
74
102
  /**
75
103
  * A PATH entry that reaches Windows through WSL drive interop
76
104
  * (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
@@ -190,13 +218,40 @@ function shQuote(value: string): string {
190
218
 
191
219
  export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath()): string {
192
220
  const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
221
+ const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|");
193
222
  return `#!/usr/bin/env sh
194
223
  # ${SHIM_MARKER}
195
224
  if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
196
225
  OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
197
226
  export OPENCODEX_API_AUTH_TOKEN
198
227
  fi
199
- case "$1" in
228
+ ocx_subcommand=""
229
+ ocx_skip_next=0
230
+ for ocx_arg in "$@"; do
231
+ if [ "$ocx_skip_next" -eq 1 ]; then
232
+ ocx_skip_next=0
233
+ continue
234
+ fi
235
+ case "$ocx_arg" in
236
+ --)
237
+ break
238
+ ;;
239
+ ${valueOptions})
240
+ ocx_skip_next=1
241
+ ;;
242
+ --help|-h|--version|-V)
243
+ ocx_subcommand="$ocx_arg"
244
+ break
245
+ ;;
246
+ -*)
247
+ ;;
248
+ *)
249
+ ocx_subcommand="$ocx_arg"
250
+ break
251
+ ;;
252
+ esac
253
+ done
254
+ case "$ocx_subcommand" in
200
255
  ${internalCommands}|--help|-h|--version|-V)
201
256
  ;;
202
257
  *)
@@ -228,6 +283,7 @@ function windowsBatchSet(name: string, value: string): string {
228
283
 
229
284
  export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
230
285
  const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
286
+ const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n");
231
287
  return `@echo off\r
232
288
  rem ${SHIM_MARKER}\r
233
289
  ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
@@ -236,11 +292,26 @@ ${windowsBatchSet("OCX_CLI", cliPath)}\r
236
292
  ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
237
293
  if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
238
294
  if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
295
+ goto scan_codex_args\r
296
+ :scan_codex_args\r
297
+ if "%~1"=="" goto ensure_ocx\r
298
+ if "%~1"=="--" goto ensure_ocx\r
299
+ ${valueOptionChecks}\r
239
300
  ${internalCommandChecks}\r
240
301
  if /I "%~1"=="--help" goto run_codex\r
241
302
  if /I "%~1"=="-h" goto run_codex\r
242
303
  if /I "%~1"=="--version" goto run_codex\r
243
304
  if /I "%~1"=="-V" goto run_codex\r
305
+ set "OCX_SCAN_ARG=%~1"\r
306
+ if "%OCX_SCAN_ARG:~0,1%"=="-" goto shift_codex_arg\r
307
+ goto ensure_ocx\r
308
+ :skip_option_value\r
309
+ shift\r
310
+ if "%~1"=="" goto ensure_ocx\r
311
+ :shift_codex_arg\r
312
+ shift\r
313
+ goto scan_codex_args\r
314
+ :ensure_ocx\r
244
315
  "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
245
316
  :run_codex\r
246
317
  "%OCX_REAL_CODEX%" %*\r
@@ -253,6 +324,7 @@ function psString(value: string): string {
253
324
 
254
325
  export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
255
326
  const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
327
+ const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", ");
256
328
  const tokenFile = serviceApiTokenFilePath();
257
329
  return `#!/usr/bin/env pwsh
258
330
  # ${SHIM_MARKER}
@@ -260,8 +332,20 @@ if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(t
260
332
  $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim()
261
333
  }
262
334
  $internalCommands = @(${internalCommands})
263
- $firstArg = if ($args.Count -gt 0) { [string]$args[0] } else { "" }
264
- $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $firstArg -or @("--help", "-h", "--version", "-V") -contains $firstArg
335
+ $valueOptions = @(${valueOptions})
336
+ $subcommand = ""
337
+ $skipNext = $false
338
+ foreach ($argValue in $args) {
339
+ $argText = [string]$argValue
340
+ if ($skipNext) { $skipNext = $false; continue }
341
+ if ($argText -eq "--") { break }
342
+ if ($valueOptions -contains $argText) { $skipNext = $true; continue }
343
+ if (@("--help", "-h", "--version", "-V") -contains $argText) { $subcommand = $argText; break }
344
+ if ($argText.StartsWith("-")) { continue }
345
+ $subcommand = $argText
346
+ break
347
+ }
348
+ $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $subcommand -or @("--help", "-h", "--version", "-V") -contains $subcommand
265
349
  if (-not $skipEnsure) {
266
350
  & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null
267
351
  }
@@ -272,7 +356,25 @@ exit $LASTEXITCODE
272
356
 
273
357
  function readState(): ShimState | null {
274
358
  try {
275
- return JSON.parse(readFileSync(statePath(), "utf8")) as ShimState;
359
+ const value = JSON.parse(readFileSync(statePath(), "utf8")) as unknown;
360
+ if (!value || typeof value !== "object") return null;
361
+ const state = value as Record<string, unknown>;
362
+ if (typeof state.platform !== "string") return null;
363
+ const validFile = (item: unknown): item is ShimFileState => {
364
+ if (!item || typeof item !== "object") return false;
365
+ const file = item as Record<string, unknown>;
366
+ return typeof file.wrapperPath === "string"
367
+ && typeof file.originalPath === "string"
368
+ && typeof file.backupPath === "string"
369
+ && (file.realPath === undefined || typeof file.realPath === "string")
370
+ && (file.preserveOnly === undefined || typeof file.preserveOnly === "boolean");
371
+ };
372
+ if (state.wrappers !== undefined) {
373
+ if (!Array.isArray(state.wrappers) || state.wrappers.length === 0 || !state.wrappers.every(validFile)) return null;
374
+ } else if (!validFile(state)) {
375
+ return null;
376
+ }
377
+ return state as unknown as ShimState;
276
378
  } catch {
277
379
  return null;
278
380
  }
@@ -431,13 +533,39 @@ export function uninstallCodexShim(): { removed: boolean; message: string } {
431
533
 
432
534
  /** True if a Codex autostart shim is currently installed (state file present). */
433
535
  export function isCodexShimInstalled(): boolean {
434
- return readState() !== null;
536
+ return diagnoseCodexShim().installed;
435
537
  }
436
538
 
437
- export function codexShimStatus(): string {
539
+ export interface CodexShimDiagnostic {
540
+ installed: boolean;
541
+ healthy: boolean;
542
+ summary: string;
543
+ }
544
+
545
+ /** Structured, secret-free shim state for CLI/GUI lifecycle diagnostics. */
546
+ export function diagnoseCodexShim(): CodexShimDiagnostic {
438
547
  const state = readState();
439
- if (!state) return "Codex autostart shim is not installed.";
440
- return stateFiles(state).map(file => {
548
+ if (!state) {
549
+ if (existsSync(statePath())) {
550
+ return {
551
+ installed: true,
552
+ healthy: false,
553
+ summary: `Codex autostart shim state is invalid or corrupt at ${statePath()}. Reinstall or remove the shim.`,
554
+ };
555
+ }
556
+ return {
557
+ installed: false,
558
+ healthy: false,
559
+ summary: "Codex autostart shim is not installed.",
560
+ };
561
+ }
562
+ const files = stateFiles(state);
563
+ const healthy = files.length > 0 && files.every(file => file.preserveOnly
564
+ ? existsSync(file.backupPath) && !existsSync(file.originalPath)
565
+ : existsSync(file.wrapperPath)
566
+ && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false))
567
+ && isHealthyShim(file.wrapperPath, state.platform));
568
+ const summary = files.map(file => {
441
569
  const wrapper = existsSync(file.wrapperPath)
442
570
  ? isShim(file.wrapperPath)
443
571
  ? "shim present"
@@ -446,4 +574,9 @@ export function codexShimStatus(): string {
446
574
  const backup = existsSync(file.backupPath) ? "present" : "missing";
447
575
  return `Codex autostart shim: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
448
576
  }).join("\n");
577
+ return { installed: true, healthy, summary };
578
+ }
579
+
580
+ export function codexShimStatus(): string {
581
+ return diagnoseCodexShim().summary;
449
582
  }
package/src/codex/sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { injectCodexConfig } from "./inject";
1
+ import { currentExternalCodexModelProvider, injectCodexConfig } from "./inject";
2
2
  import { printProjectCodexConfigWarnings, groupProjectCodexConfigWarningsByPath, type ProjectCodexConfigWarning } from "./project-config-warnings";
3
3
  import { refreshCodexModelCatalog } from "./refresh";
4
4
  import { applyProxyEnv, loadConfig } from "../config";
@@ -19,6 +19,7 @@ export interface CodexSyncResult {
19
19
  interface CodexSyncDeps {
20
20
  refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
21
21
  injectCodexConfig: typeof injectCodexConfig;
22
+ currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
22
23
  }
23
24
 
24
25
  const defaultDeps: CodexSyncDeps = {
@@ -32,8 +33,22 @@ export async function syncModelsToCodex(
32
33
  log: Pick<Console, "log" | "error"> | null = console,
33
34
  deps: CodexSyncDeps = defaultDeps,
34
35
  ): Promise<CodexSyncResult> {
35
- applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process
36
36
  const p = port ?? config.port ?? 10100;
37
+ const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
38
+ if (externalProvider) {
39
+ const result = await deps.injectCodexConfig(p, config, {});
40
+ log?.log(result.message);
41
+ return {
42
+ ok: result.success,
43
+ added: 0,
44
+ catalogPath: null,
45
+ catalogExists: false,
46
+ cacheSynced: false,
47
+ message: result.message,
48
+ };
49
+ }
50
+
51
+ applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process
37
52
  let added = 0;
38
53
  let catalogPath: string | null = null;
39
54
  let catalogPathForInjection: string | null | undefined;
package/src/config.ts CHANGED
@@ -422,6 +422,18 @@ export function positiveIntegerConfigError(value: unknown, field: string): strin
422
422
  return null;
423
423
  }
424
424
 
425
+ export function booleanRecordConfigError(value: unknown, field: string): string | null {
426
+ if (value === undefined) return null;
427
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
428
+ const prototype = Object.getPrototypeOf(value);
429
+ if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
430
+ for (const [key, entry] of Object.entries(value)) {
431
+ if (!key.trim()) return `${field} keys must be nonblank model ids`;
432
+ if (typeof entry !== "boolean") return `${field}.${key} must be a boolean`;
433
+ }
434
+ return null;
435
+ }
436
+
425
437
  const configSchema = z.object({
426
438
  port: z.number().int().min(0).max(65535).default(10100),
427
439
  providers: z.record(z.string(), providerConfigSchema),
@@ -505,6 +517,17 @@ const configSchema = z.object({
505
517
  message: maxInputError,
506
518
  });
507
519
  }
520
+ const reasoningSummariesError = booleanRecordConfigError(
521
+ (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
522
+ "modelSupportsReasoningSummaries",
523
+ );
524
+ if (reasoningSummariesError) {
525
+ ctx.addIssue({
526
+ code: "custom",
527
+ path: ["providers", name, "modelSupportsReasoningSummaries"],
528
+ message: reasoningSummariesError,
529
+ });
530
+ }
508
531
  const defaultMaxOutputError = positiveIntegerConfigError(
509
532
  (provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens,
510
533
  "defaultMaxOutputTokens",
package/src/lib/errors.ts CHANGED
@@ -99,9 +99,18 @@ export function classifyError(status: number, type: string, message: string): Oc
99
99
  ) {
100
100
  return { message, type: "invalid_request_error", code: "context_length_exceeded" };
101
101
  }
102
+ // "Cursor resource limit exceeded" is emitted only for explicit request-size overflow
103
+ // details (isCursorRequestTooLargeDetail in cursor-errors.ts); quota-style resource
104
+ // exhaustion arrives as "Cursor rate limit exceeded" and falls through to 429 below.
102
105
  if (text.includes("cursor resource limit exceeded")) {
103
106
  return { message, type: "invalid_request_error", code: "tool_catalog_too_large" };
104
107
  }
108
+ // The Cursor adapter's classified rate-limit prefix is authoritative: its DETAIL may echo
109
+ // quota wording ("... quota exhausted") that would otherwise hit the insufficient_quota
110
+ // branch below and break the planned retry-with-backoff contract (WP3 review blocker 1).
111
+ if (text.includes("cursor rate limit exceeded")) {
112
+ return { message, type: "rate_limit_error", code: "rate_limit_exceeded" };
113
+ }
105
114
  if (
106
115
  text.includes("insufficient_quota") ||
107
116
  text.includes("exceeded your current quota") ||
@@ -202,6 +211,8 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
202
211
  const lower = message.toLowerCase();
203
212
  // Client aborts (e.g. mid web-search loop) must not look like upstream 502s in /api/logs.
204
213
  if (isClientClosedMessage(lower)) return 499;
214
+ // See classifyError: this prefix now only means explicit request-size overflow (400);
215
+ // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429.
205
216
  if (lower.includes("cursor resource limit exceeded")) return 400;
206
217
  if (
207
218
  lower.includes("resource_exhausted") ||
@@ -167,3 +167,36 @@ export function resolveAntigravityEffortWireModel(
167
167
  // Rule 5: everything else.
168
168
  return { wireModelId: resolveAntigravityWireModelId(modelId) };
169
169
  }
170
+
171
+
172
+ // ── Usage aggregation reverse map (picker/call base identity) ──
173
+ // Effort wire IDs and compatibility aliases must collapse to the same picker-visible
174
+ // base model that users invoke after effort routing. Historical logs store suffix IDs;
175
+ // summary aggregation consults this reverse map so one call model = one usage row.
176
+ const ANTIGRAVITY_USAGE_BASE_BY_ID: Record<string, string> = (() => {
177
+ const rev: Record<string, string> = {};
178
+ for (const base of ANTIGRAVITY_MODELS) rev[base] = base;
179
+ for (const [base, effortMap] of Object.entries(ANTIGRAVITY_EFFORT_WIRE_MAP)) {
180
+ rev[base] = base;
181
+ for (const wire of Object.values(effortMap)) rev[wire] = base;
182
+ }
183
+ for (const [alias, wire] of Object.entries(ANTIGRAVITY_MODEL_ALIASES)) {
184
+ const base = rev[wire] ?? rev[alias];
185
+ if (base) rev[alias] = base;
186
+ // If alias is itself a base/wire already mapped, keep that mapping.
187
+ else if (rev[wire]) rev[alias] = rev[wire]!;
188
+ }
189
+ // Visible aliases that only appear in ANTIGRAVITY_VISIBLE_MODEL_ALIASES are already
190
+ // included via ANTIGRAVITY_MODEL_ALIASES. Identity bases without effort maps remain.
191
+ return rev;
192
+ })();
193
+
194
+ /**
195
+ * Canonical picker/call model id for usage aggregation.
196
+ * Unknown ids stay identity so future CCA models do not invent mappings.
197
+ */
198
+ export function canonicalAntigravityUsageModel(modelId: string): string {
199
+ const id = modelId.trim();
200
+ if (!id) return modelId;
201
+ return ANTIGRAVITY_USAGE_BASE_BY_ID[id] ?? id;
202
+ }
@@ -5,6 +5,7 @@ export const KIRO_MODELS = [
5
5
  "gpt-5.6-terra",
6
6
  "gpt-5.6-luna",
7
7
  "claude-sonnet-5",
8
+ "claude-opus-5",
8
9
  "claude-opus-4.8",
9
10
  "claude-opus-4.7",
10
11
  "claude-opus-4.6",
@@ -28,6 +29,7 @@ export const KIRO_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
28
29
  "gpt-5.6-terra": 272_000,
29
30
  "gpt-5.6-luna": 272_000,
30
31
  "claude-sonnet-5": 1_000_000,
32
+ "claude-opus-5": 1_000_000,
31
33
  "claude-opus-4.8": 1_000_000,
32
34
  "claude-opus-4.7": 1_000_000,
33
35
  "claude-opus-4.6": 1_000_000,
@@ -100,8 +100,8 @@ export type ProviderConfigSeed = Pick<
100
100
  // same static model seed.
101
101
  // 260710 context refresh: Tier-2 evidence in
102
102
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
103
- const ANTHROPIC_MODELS = ["claude-fable-5", "claude-sonnet-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
104
- const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-haiku-4-5": 200_000 };
103
+ const ANTHROPIC_MODELS = ["claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
104
+ const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-haiku-4-5": 200_000 };
105
105
 
106
106
  const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"];
107
107
  const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];