@bitkyc08/opencodex 2.25.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/bin/ocx.mjs +59 -7
  2. package/gui/dist/assets/index-RL6b1bTV.js +102 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/base.ts +18 -0
  6. package/src/adapters/client-fingerprint.ts +0 -2
  7. package/src/adapters/cursor/http1-bidi.ts +361 -0
  8. package/src/adapters/cursor/live-models.ts +117 -30
  9. package/src/adapters/cursor/live-transport.ts +137 -56
  10. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  11. package/src/adapters/cursor/native-exec-network.ts +1 -1
  12. package/src/adapters/cursor/native-exec-shell.ts +0 -1
  13. package/src/adapters/cursor/protobuf-request.ts +89 -21
  14. package/src/adapters/cursor/tool-definitions.ts +18 -7
  15. package/src/adapters/cursor/tool-result-normalize.ts +92 -0
  16. package/src/adapters/cursor/transport.ts +7 -0
  17. package/src/adapters/cursor.ts +2 -0
  18. package/src/adapters/google-http.ts +38 -10
  19. package/src/adapters/google.ts +23 -3
  20. package/src/adapters/openai-chat.ts +38 -13
  21. package/src/adapters/openai-responses.ts +129 -9
  22. package/src/adapters/registry.ts +30 -1
  23. package/src/bridge.ts +50 -12
  24. package/src/chat/inbound.ts +1 -0
  25. package/src/claude/agents-inject.ts +3 -2
  26. package/src/cli/dispatch.ts +19 -6
  27. package/src/cli/help.ts +2 -1
  28. package/src/cli/integrations.ts +35 -0
  29. package/src/cli/minimax.ts +8 -2
  30. package/src/cli/registry.ts +13 -2
  31. package/src/clients/config-export.ts +120 -1
  32. package/src/codex/account-store.ts +17 -1
  33. package/src/codex/auth-api.ts +51 -17
  34. package/src/codex/catalog/effort.ts +8 -5
  35. package/src/codex/catalog/provider-fetch.ts +39 -27
  36. package/src/codex/catalog/sync.ts +30 -8
  37. package/src/codex/inject.ts +39 -13
  38. package/src/codex/main-account.ts +29 -1
  39. package/src/codex/plan-from-token.ts +140 -0
  40. package/src/codex/plan.ts +25 -0
  41. package/src/codex/prompt-journal.ts +6 -2
  42. package/src/codex/quota-rejection.ts +21 -7
  43. package/src/codex/refresh.ts +4 -2
  44. package/src/codex/sync.ts +106 -3
  45. package/src/codex/warmup.ts +187 -81
  46. package/src/config.ts +85 -46
  47. package/src/generated/compatibility-version.json +127 -83
  48. package/src/grok/inject.ts +1 -1
  49. package/src/images/loop.ts +1 -0
  50. package/src/integrations/registry.ts +7 -0
  51. package/src/lab/automation/config-persistence.ts +2 -2
  52. package/src/lab/automation/persistence.ts +2 -2
  53. package/src/lab/ledger/purge.ts +2 -2
  54. package/src/lab/subject/behavior-fingerprint.ts +2 -2
  55. package/src/lib/config-ownership.ts +5 -2
  56. package/src/lib/destination-policy.ts +18 -1
  57. package/src/lib/provider-outbound.ts +7 -0
  58. package/src/lib/redact.ts +11 -0
  59. package/src/lib/tool-argument-integers.ts +50 -6
  60. package/src/lib/upstream-http-version.ts +57 -0
  61. package/src/lib/upstream-retry.ts +2 -1
  62. package/src/lib/windows-atomic-replace.ts +155 -0
  63. package/src/lib/windows-service-wrappers.ts +72 -0
  64. package/src/oauth/google-antigravity.ts +2 -2
  65. package/src/oauth/index.ts +60 -8
  66. package/src/providers/antigravity-models.ts +58 -4
  67. package/src/providers/command-code-efforts.ts +36 -9
  68. package/src/providers/context-cap.ts +9 -0
  69. package/src/providers/derive.ts +4 -0
  70. package/src/providers/fastwire.ts +453 -0
  71. package/src/providers/registry.ts +21 -1
  72. package/src/providers/service-tier.ts +173 -89
  73. package/src/responses/parser.ts +25 -16
  74. package/src/responses/reasoning-replay-cache.ts +30 -0
  75. package/src/responses/thought-signature-replay.ts +74 -4
  76. package/src/router.ts +6 -0
  77. package/src/routing/compatibility/behavior.ts +27 -16
  78. package/src/server/index.ts +10 -1
  79. package/src/server/management/oauth-account-routes.ts +10 -2
  80. package/src/server/management/shared.ts +1 -1
  81. package/src/server/management/system-routes.ts +15 -0
  82. package/src/server/request-log.ts +57 -3
  83. package/src/server/responses/agent-task-recovery.ts +6 -1
  84. package/src/server/responses/core.ts +209 -40
  85. package/src/server/responses/fetch-helpers.ts +15 -36
  86. package/src/server/responses/responses-field-backfill.ts +173 -0
  87. package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
  88. package/src/service.ts +38 -36
  89. package/src/storage/cleanup.ts +2 -2
  90. package/src/tray/windows.ts +3 -2
  91. package/src/types.ts +101 -14
  92. package/src/update/job.ts +9 -18
  93. package/src/update/transactional-install.d.mts +22 -0
  94. package/src/update/transactional-install.mjs +259 -0
  95. package/src/usage/cost.ts +34 -5
  96. package/src/usage/log.ts +74 -4
  97. package/src/vision/anthropic-describe.ts +10 -6
  98. package/src/web-search/anthropic-executor.ts +8 -6
  99. package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
@@ -416,6 +416,22 @@ export function mcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
416
416
  return join(mcodeHomeDir(env, home), "config.yaml");
417
417
  }
418
418
 
419
+ /**
420
+ * ZCode (Z.ai's desktop client) keeps everything under `~/.zcode`; custom
421
+ * providers live in `v2/config.json`. `ZCODE_DATA_DIR` mirrors the other
422
+ * clients' override convention; relative overrides are refused for the same
423
+ * reason as MCode's.
424
+ */
425
+ export function zcodeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
426
+ const override = env.ZCODE_DATA_DIR?.trim();
427
+ if (override) return absoluteClientPath(override, home, "ZCODE_DATA_DIR");
428
+ return join(home, ".zcode");
429
+ }
430
+
431
+ export function zcodeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
432
+ return join(zcodeHomeDir(env, home), "v2", "config.json");
433
+ }
434
+
419
435
  /**
420
436
  * One proxy-routed model destined for a client config. Deliberately narrower than
421
437
  * `CatalogModel` so a serializer cannot reach for a field that does not survive the
@@ -456,7 +472,8 @@ export type ExportClientId =
456
472
  | "kimi"
457
473
  | "gajae"
458
474
  | "dsh"
459
- | "mcode";
475
+ | "mcode"
476
+ | "zcode";
460
477
 
461
478
  export interface ExportClientSpec {
462
479
  id: ExportClientId;
@@ -876,6 +893,36 @@ export interface McodeGeneratedConfig {
876
893
  custom_provider: Record<string, McodeProviderBlock>;
877
894
  }
878
895
 
896
+ /**
897
+ * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated
898
+ * live against ZCode 3.7.7). `kind: "anthropic"` selects the Anthropic
899
+ * Messages protocol, which the proxy serves at `/v1/messages`. `apiKeyRequired`
900
+ * keeps ZCode's UI from prompting for a key it does not need on loopback; the
901
+ * serialized key is always the non-secret loopback placeholder.
902
+ */
903
+ export interface ZcodeModelEntry {
904
+ name?: string;
905
+ limit?: { context: number; output?: number };
906
+ modalities: { input: string[]; output: string[] };
907
+ }
908
+
909
+ export interface ZcodeProviderBlock {
910
+ name: "OpenCodex";
911
+ kind: "anthropic";
912
+ enabled: true;
913
+ source: "custom";
914
+ options: {
915
+ apiKey: string;
916
+ baseURL: string;
917
+ apiKeyRequired: true;
918
+ };
919
+ models: Record<string, ZcodeModelEntry>;
920
+ }
921
+
922
+ export interface ZcodeGeneratedConfig {
923
+ provider: Record<string, ZcodeProviderBlock>;
924
+ }
925
+
879
926
  /**
880
927
  * Pi's `~/.pi/agent/models.json` shape. `models` is an ARRAY (identity lives in `id`),
881
928
  * unlike OpenCode's keyed object.
@@ -1208,6 +1255,53 @@ function buildMcodeClientConfig(ctx: ExportContext): McodeGeneratedConfig {
1208
1255
  };
1209
1256
  }
1210
1257
 
1258
+ /**
1259
+ * ZCode dials the Anthropic Messages surface, so `baseURL` is the proxy origin
1260
+ * without the `/v1` suffix (ZCode appends `/v1/messages` itself — the same
1261
+ * shape its builtin Z.ai providers use). Model ids are the proxy's canonical
1262
+ * `provider/id` selectors, which `/v1/messages` resolves directly. Context
1263
+ * limits follow the authoritative-window rule: a model without one ships
1264
+ * without `limit` rather than guessing. Modalities are ZCode's observed
1265
+ * `text`-floor vocabulary; image-capable rows advertise image input.
1266
+ */
1267
+ function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig {
1268
+ const models: Record<string, ZcodeModelEntry> = {};
1269
+ for (const model of normalizeExportModels(ctx.models)) {
1270
+ const input = inputModalitiesForClient("pi", model.inputModalities);
1271
+ if (input === null) continue;
1272
+ const entry: ZcodeModelEntry = {
1273
+ name: exportModelLabel(model),
1274
+ modalities: { input, output: ["text"] },
1275
+ };
1276
+ // `limit.context` follows the authoritative-window rule. `output` is
1277
+ // deliberately absent: ZCode's schema makes it optional and we have no
1278
+ // authoritative output budget to assert (reviewer finding: an emitted
1279
+ // stand-in would be a guessed capability, exactly what "no metadata is
1280
+ // guessed" forbids).
1281
+ const context = authoritativeContextWindow(model.contextWindow);
1282
+ if (context !== undefined) {
1283
+ entry.limit = { context };
1284
+ }
1285
+ models[model.namespaced] = entry;
1286
+ }
1287
+ return {
1288
+ provider: {
1289
+ [OPENCODE_PROVIDER_ID]: {
1290
+ name: "OpenCodex",
1291
+ kind: "anthropic",
1292
+ enabled: true,
1293
+ source: "custom",
1294
+ options: {
1295
+ apiKey: LOOPBACK_API_KEY_PLACEHOLDER,
1296
+ baseURL: ctx.baseUrl.replace(/\/v1\/?$/, ""),
1297
+ apiKeyRequired: true,
1298
+ },
1299
+ models,
1300
+ },
1301
+ },
1302
+ };
1303
+ }
1304
+
1211
1305
  /**
1212
1306
  * Per-client model counts, read back off the SERIALIZED document rather than
1213
1307
  * recomputed from the input rows: `modelsWithoutLimits` drives a GUI line about
@@ -1263,6 +1357,11 @@ function summarizeMcode(document: unknown): { modelCount: number; modelsWithoutL
1263
1357
  return { modelCount: models.length, modelsWithoutLimits: 0 };
1264
1358
  }
1265
1359
 
1360
+ function summarizeZcode(document: unknown): { modelCount: number; modelsWithoutLimits: number } {
1361
+ const models = Object.values((document as ZcodeGeneratedConfig | undefined)?.provider?.[OPENCODE_PROVIDER_ID]?.models ?? {});
1362
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => !model.limit).length };
1363
+ }
1364
+
1266
1365
  /** One fragment at `path`, built from this client's own document. */
1267
1366
  function singleFragment(clientId: ExportClientId, path: readonly string[], value: unknown): ManagedContribution {
1268
1367
  return { clientId, fragments: [{ path, value }] };
@@ -1324,6 +1423,11 @@ function buildMcodeContribution(ctx: ExportContext): ManagedContribution {
1324
1423
  return singleFragment("mcode", ["custom_provider", OPENCODE_PROVIDER_ID], doc.custom_provider[OPENCODE_PROVIDER_ID]);
1325
1424
  }
1326
1425
 
1426
+ function buildZcodeContribution(ctx: ExportContext): ManagedContribution {
1427
+ const doc = buildZcodeClientConfig(ctx);
1428
+ return singleFragment("zcode", ["provider", OPENCODE_PROVIDER_ID], doc.provider[OPENCODE_PROVIDER_ID]);
1429
+ }
1430
+
1327
1431
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1328
1432
  opencode: {
1329
1433
  id: "opencode",
@@ -1447,6 +1551,21 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1447
1551
  // header field, so real keys are never serialized and remote binds refuse.
1448
1552
  loopbackOnly: true,
1449
1553
  },
1554
+ zcode: {
1555
+ id: "zcode",
1556
+ filename: "config.json",
1557
+ destination: env => zcodeConfigPath(env),
1558
+ apiKeyEnv: "",
1559
+ exportHint: "ZCode reads a non-secret placeholder from v2/config.json; loopback needs no key.",
1560
+ build: buildZcodeClientConfig,
1561
+ format: "json",
1562
+ summarize: summarizeZcode,
1563
+ buildContribution: buildZcodeContribution,
1564
+ // ZCode persists the credential in its own file and has no dedicated
1565
+ // proxy-admission header field, so real keys are never serialized and
1566
+ // remote binds refuse — same reasoning as MCode.
1567
+ loopbackOnly: true,
1568
+ },
1450
1569
  };
1451
1570
 
1452
1571
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -386,6 +386,19 @@ function findFreshCredentialForGrant(
386
386
  return null;
387
387
  }
388
388
 
389
+ async function notePlanFromRefreshedAccessToken(
390
+ id: string,
391
+ accessToken: string,
392
+ generation: number,
393
+ ): Promise<void> {
394
+ try {
395
+ const { noteCodexAccountAccessToken } = await import("./plan-from-token");
396
+ noteCodexAccountAccessToken(id, accessToken, generation);
397
+ } catch {
398
+ // Derived plan metadata must not fail credential refresh.
399
+ }
400
+ }
401
+
389
402
  export async function getValidCodexToken(id: string): Promise<CodexTokenResult> {
390
403
  const record = readCodexAccountRecord(id);
391
404
  const cred = record?.deletedAt == null ? record?.credential : undefined;
@@ -415,10 +428,12 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
415
428
  if (!saveCodexAccountCredentialIfGeneration(id, current.generation, refreshed.credential)) {
416
429
  throw new CodexCredentialGenerationConflictError();
417
430
  }
431
+ const generation = current.generation + 1;
432
+ await notePlanFromRefreshedAccessToken(id, refreshed.credential.accessToken, generation);
418
433
  return {
419
434
  accessToken: refreshed.credential.accessToken,
420
435
  chatgptAccountId: refreshed.credential.chatgptAccountId,
421
- generation: current.generation + 1,
436
+ generation,
422
437
  };
423
438
  }
424
439
  return getValidCodexToken(id);
@@ -520,6 +535,7 @@ export async function getValidCodexToken(id: string): Promise<CodexTokenResult>
520
535
  flight = { promise: refreshPromise, startedAt: Date.now(), abort };
521
536
  refreshLocks.set(refreshGrantFingerprint, flight);
522
537
  const result = await refreshPromise;
538
+ await notePlanFromRefreshedAccessToken(id, result.accessToken, result.generation);
523
539
  return {
524
540
  accessToken: result.accessToken,
525
541
  chatgptAccountId: result.chatgptAccountId,
@@ -83,7 +83,7 @@ export {
83
83
  updateAccountQuota,
84
84
  } from "./quota";
85
85
  import { extractAccountId } from "../oauth/chatgpt";
86
- import { getMainAccountPlan, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
86
+ import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
87
87
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper";
88
88
  import { reconcileLiveStateStores } from "../lib/state-store-registrations";
89
89
  import {
@@ -98,7 +98,7 @@ import {
98
98
  } from "./main-account-cache";
99
99
  export { clearMainAccountInfoCache } from "./main-account-cache";
100
100
  import { maskEmail } from "../lib/privacy";
101
- import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
101
+ import { codexWarmupFailureReason, warmCodexAccount } from "./warmup";
102
102
  export { maskEmail } from "../lib/privacy";
103
103
  import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types";
104
104
  import type { CatalogDisposition } from "./convergence-types";
@@ -396,13 +396,10 @@ async function verifyCodexAccountWarmup(
396
396
  return { ok: true, validatedAt: Date.now() };
397
397
  } catch (err) {
398
398
  const reason = codexWarmupFailureReason(err);
399
- const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
400
399
  return {
401
400
  ok: false,
402
401
  response: jsonResponse({
403
- error: upstream
404
- ? `Codex account warmup failed: ${upstream}`
405
- : "Codex account warmup failed. Reauthenticate the account and try again.",
402
+ error: "Codex account warmup failed. Reauthenticate the account and try again.",
406
403
  code: "codex_warmup_failed",
407
404
  reason,
408
405
  accountId,
@@ -569,12 +566,31 @@ const MAIN_TERMINAL_AUTH_CODES = new Set([
569
566
  "invalid_refresh_token",
570
567
  ]);
571
568
 
572
- async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
573
- if (resp.status === 401) return true;
569
+ /**
570
+ * A WHAM 401 is not itself proof the local credential died. Upstream edges can
571
+ * transiently reject a still-valid access token (region/anti-abuse/rotation
572
+ * races), and fail-closing on every bare 401 makes a healthy main account flip
573
+ * needs-reauth on the next GUI quota poll. Only treat the response as terminal
574
+ * when the body carries a known terminal code or the local access token is not
575
+ * verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose
576
+ * `exp` cannot be decoded is NOT live — an undecodable token that vouched for
577
+ * itself would make a real 401 permanently transient.
578
+ */
579
+ async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise<boolean> {
580
+ if (resp.status === 401) {
581
+ if (!accessTokenLive) return true;
582
+ const code = await readMainAuthErrorCode(resp);
583
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
584
+ }
574
585
  if (resp.status !== 403) return false;
586
+ const code = await readMainAuthErrorCode(resp);
587
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
588
+ }
589
+
590
+ async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
575
591
  try {
576
592
  const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 });
577
- if (!body.displaySafe) return false;
593
+ if (!body.displaySafe) return undefined;
578
594
  const parsed = JSON.parse(body.text) as {
579
595
  detail?: { code?: unknown } | string;
580
596
  error?: { code?: unknown } | string;
@@ -585,9 +601,9 @@ async function isTerminalMainAuthResponse(resp: Response): Promise<boolean> {
585
601
  : typeof parsed.error === "object" && parsed.error !== null
586
602
  ? parsed.error.code
587
603
  : parsed.code;
588
- return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
604
+ return code;
589
605
  } catch {
590
- return false;
606
+ return undefined;
591
607
  }
592
608
  }
593
609
 
@@ -707,7 +723,7 @@ async function fetchMainAccountInfoWhileOwned(
707
723
  signal: AbortSignal.timeout(8000),
708
724
  });
709
725
  if (!resp.ok) {
710
- const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
726
+ const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
711
727
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease);
712
728
  if (retried) return retried;
713
729
  if (terminalAuthFailure) {
@@ -854,6 +870,15 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
854
870
  accepted.push(update);
855
871
  if (persistedAccount.plan !== update.plan) {
856
872
  persistedAccount.plan = update.plan;
873
+ // WHAM is the authoritative plan source: stamp provenance so a later JWT
874
+ // reconcile cannot overwrite this observation within the same credential
875
+ // generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only
876
+ // alongside a real plan change: a steady-state refresh whose plan is
877
+ // unchanged must stay write-free (no-config-write contract), and an
878
+ // unchanged value needs no fence — a JWT rewrite to the same text is a
879
+ // no-op under the caller's own equality check.
880
+ persistedAccount.planSource = "wham";
881
+ persistedAccount.planCredentialGeneration = update.credentialGeneration;
857
882
  changed = true;
858
883
  }
859
884
  }
@@ -873,6 +898,8 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
873
898
  const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId);
874
899
  if (liveAccount) {
875
900
  liveAccount.plan = update.plan;
901
+ liveAccount.planSource = "wham";
902
+ liveAccount.planCredentialGeneration = update.credentialGeneration;
876
903
  }
877
904
  }
878
905
  }
@@ -1791,7 +1818,7 @@ export async function handleCodexAuthAPI(
1791
1818
  const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() };
1792
1819
  codexAuthLoginState.set(flowId, loginOwner);
1793
1820
  try {
1794
- const { startLoginFlow, getLoginStatus } = await import("../oauth");
1821
+ const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth");
1795
1822
  const result = await startLoginFlow("chatgpt", { forceLogin: true });
1796
1823
 
1797
1824
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
@@ -1993,7 +2020,13 @@ export async function handleCodexAuthAPI(
1993
2020
  break;
1994
2021
  }
1995
2022
  if (st.done && st.error) {
1996
- setCodexLoginState(flowId, { status: "error", error: st.error, doneAt: Date.now() });
2023
+ setCodexLoginState(flowId, {
2024
+ status: "error",
2025
+ // startLoginFlow projects background failures before storing login status, so
2026
+ // fixed actionable OAuth messages retain their type-derived remediation here.
2027
+ error: st.error,
2028
+ doneAt: Date.now(),
2029
+ });
1997
2030
  completed = true;
1998
2031
  break;
1999
2032
  }
@@ -2011,7 +2044,7 @@ export async function handleCodexAuthAPI(
2011
2044
  ? "Configuration is busy; retry login shortly."
2012
2045
  : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError
2013
2046
  ? "Credential refresh is busy; retry login shortly."
2014
- : error instanceof Error ? error.message : String(error);
2047
+ : publicOAuthAuthenticationErrorMessage(error);
2015
2048
  setCodexLoginState(flowId, {
2016
2049
  status: "error",
2017
2050
  error: message,
@@ -2028,7 +2061,7 @@ export async function handleCodexAuthAPI(
2028
2061
  } catch (e) {
2029
2062
  if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId);
2030
2063
  const msg = e instanceof Error ? e.message : String(e);
2031
- if (msg.includes("already in progress")) {
2064
+ if (msg === "A login for chatgpt is already in progress") {
2032
2065
  return jsonResponse({ error: msg, status: "pending" }, 409);
2033
2066
  }
2034
2067
  if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {
@@ -2036,7 +2069,8 @@ export async function handleCodexAuthAPI(
2036
2069
  response.headers.set("Retry-After", "1");
2037
2070
  return response;
2038
2071
  }
2039
- return jsonResponse({ error: msg }, 500);
2072
+ const { publicOAuthAuthenticationErrorMessage } = await import("../oauth");
2073
+ return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500);
2040
2074
  }
2041
2075
  }
2042
2076
 
@@ -12,7 +12,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo
12
12
  import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
13
13
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
14
14
  import { getProviderRegistryEntry } from "../../providers/registry";
15
- import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
15
+ import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
16
16
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
17
17
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
18
18
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -122,11 +122,14 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel)
122
122
  // so genuine upstream marketing names are preserved untouched.
123
123
  const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
124
124
  if (displayName) entry.display_name = displayName;
125
- if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
126
- entry.context_window = model.contextWindow;
127
- entry.max_context_window = model.contextWindow;
125
+ const resolvedContext = typeof model.contextWindow === "number" && model.contextWindow > 0
126
+ ? model.contextWindow
127
+ : (model.contextCap !== undefined ? resolveUnknownRoutedContextWindow(model.contextCap) : undefined);
128
+ if (typeof resolvedContext === "number" && resolvedContext > 0) {
129
+ entry.context_window = resolvedContext;
130
+ entry.max_context_window = resolvedContext;
128
131
  entry.auto_compact_token_limit = Math.min(
129
- Math.floor(model.contextWindow * 0.9),
132
+ Math.floor(resolvedContext * 0.9),
130
133
  model.maxInputTokens ?? Number.POSITIVE_INFINITY,
131
134
  );
132
135
  }
@@ -33,13 +33,13 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo
33
33
  import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
34
34
  import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
35
35
  import {
36
- captureServiceTierAdapterAuthority,
36
+ captureFastPolicyAuthority,
37
37
  serviceTierSupportForModel,
38
- type CapturedServiceTierAdapterAuthority,
39
38
  } from "../../providers/service-tier";
39
+ import type { FastPolicyAuthority } from "../../providers/fastwire";
40
40
  import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
41
41
  import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
42
- import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap";
42
+ import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
43
43
  import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec";
44
44
  import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
45
45
  import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
@@ -155,7 +155,7 @@ interface CapturedProviderGather {
155
155
  readonly discovery: ResolvedProviderModelDiscovery;
156
156
  readonly policy: CatalogProviderDiscoveryPolicySnapshot;
157
157
  readonly request: CapturedModelsRequest;
158
- readonly serviceTierAdapterAuthority: CapturedServiceTierAdapterAuthority;
158
+ readonly fastPolicyAuthority: FastPolicyAuthority;
159
159
  readonly observedAuth?: ModelsAuthResolution;
160
160
  /**
161
161
  * Configured model ids this provider must keep even when live discovery omits
@@ -408,12 +408,12 @@ function captureProviderGather(
408
408
  const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
409
409
  enrichProviderFromRegistry(name, enriched);
410
410
  const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
411
- const serviceTierAdapterAuthority = captureServiceTierAdapterAuthority(
411
+ const provider = recursivelyFreeze(enriched);
412
+ const fastPolicyAuthority = captureFastPolicyAuthority(
412
413
  name,
413
- enriched,
414
+ provider,
414
415
  registryTransportMatch,
415
416
  );
416
- const provider = recursivelyFreeze(enriched);
417
417
  const observedAuth = authResolver.kind === "observed"
418
418
  && provider.authMode !== "forward"
419
419
  && provider.liveModels !== false
@@ -449,7 +449,7 @@ function captureProviderGather(
449
449
  discovery,
450
450
  policy,
451
451
  request,
452
- serviceTierAdapterAuthority,
452
+ fastPolicyAuthority,
453
453
  ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}),
454
454
  ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0
455
455
  ? { retainConfiguredModelIds }
@@ -518,7 +518,7 @@ function captureGatherFlight(
518
518
  // It is the one member of a provider row that is legitimately a function,
519
519
  // so it is dropped here rather than allowed to break every encode.
520
520
  provider: omitProviderTransportExecutor(provider.provider),
521
- serviceTierAdapterAuthority: provider.serviceTierAdapterAuthority,
521
+ fastPolicyAuthority: provider.fastPolicyAuthority,
522
522
  // Combo retention is capture-time state, not a provider-row field. Two
523
523
  // gathers that share providers but differ in combo targets must not join.
524
524
  retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(),
@@ -648,15 +648,16 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
648
648
  const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
649
649
  const supportsServiceTier = serviceTierSupportForModel(prov, model.id, name);
650
650
  const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model;
651
+ // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。
652
+ const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0
653
+ ? model.contextWindow
654
+ : undefined;
655
+ const hintedWindow = discoveredWindow !== undefined
656
+ ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow)
657
+ : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined));
651
658
  const hinted = {
652
659
  ...modelWithoutServiceTier,
653
- ...(configuredCap !== undefined
654
- ? {
655
- contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0
656
- ? Math.min(model.contextWindow, configuredCap)
657
- : configuredCap,
658
- }
659
- : {}),
660
+ ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}),
660
661
  ...(inputModalities ? { inputModalities } : {}),
661
662
  ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}),
662
663
  ...(configuredMaxInput !== undefined
@@ -695,9 +696,11 @@ export function applyConfigHintsToCachedModels(name: string, prov: OcxProviderCo
695
696
 
696
697
 
697
698
  /**
698
- * Last-resort context window for combo member synthesis when discovery and
699
- * provider config both omit one. Matches the catalog entry default in
700
- * `normalizeRoutedCatalogEntry` so incomplete live rows still catalog.
699
+ * Last-resort context window for combo member synthesis when discovery,
700
+ * provider config, and an enabled Context cap all omit one. Matches the
701
+ * catalog entry default in `normalizeRoutedCatalogEntry` so incomplete live
702
+ * rows still catalog. An enabled Context cap is the operator-facing window,
703
+ * not a clamp on this placeholder.
701
704
  */
702
705
  const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000;
703
706
 
@@ -715,9 +718,9 @@ interface ComboCatalogMemberFallback {
715
718
  * lacks a positive contextWindow, synthesize from the (registry-enriched)
716
719
  * provider config so combos remain catalogued when targets are configured but
717
720
  * discovery metadata is incomplete. Disabled providers stay unresolved.
718
- * When hints still omit contextWindow, prefer known maxInputTokens, else
719
- * COMBO_MEMBER_CONTEXT_FALLBACK so a live row without ctx does not drop the
720
- * whole combo from the public catalog.
721
+ * When hints still omit contextWindow, prefer known maxInputTokens, else the
722
+ * enabled Context cap, else COMBO_MEMBER_CONTEXT_FALLBACK so a live row
723
+ * without ctx does not drop the whole combo from the public catalog.
721
724
  */
722
725
  export function resolveComboCatalogMember(
723
726
  target: { provider: string; model: string },
@@ -806,12 +809,15 @@ export function resolveComboCatalogMember(
806
809
  const uncappedContext = hintedContext
807
810
  ?? knownMaxInput
808
811
  ?? fallbackContext
809
- ?? (existing || prov ? COMBO_MEMBER_CONTEXT_FALLBACK : undefined);
812
+ ?? (existing || prov ? resolveUnknownRoutedContextWindow(contextCap) : undefined);
810
813
  if (uncappedContext === undefined) return undefined;
811
- const usedFallback = hintedContext === undefined;
812
- const cappedContext = applyProviderContextCap(uncappedContext, contextCap);
814
+ // 真发现值才压低。resolveUnknownRoutedContextWindow 已经把 cap 当成窗口填进去了,不能再 min 一次。
815
+ const usedDiscoveredWindow = hintedContext !== undefined || knownMaxInput !== undefined || fallbackContext !== undefined;
816
+ const cappedContext = usedDiscoveredWindow
817
+ ? applyProviderContextCap(uncappedContext, contextCap)
818
+ : uncappedContext;
813
819
  const contextWindow = cappedContext ?? uncappedContext;
814
- const fallbackCapped = usedFallback
820
+ const fallbackCapped = usedDiscoveredWindow
815
821
  && contextCap !== undefined
816
822
  && cappedContext !== undefined
817
823
  && cappedContext !== uncappedContext;
@@ -1208,7 +1214,13 @@ async function fetchProviderModelsWithAuth(
1208
1214
  "degraded",
1209
1215
  );
1210
1216
  }
1211
- const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl });
1217
+ const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
1218
+ const liveResult = await fetchCursorUsableModels({
1219
+ apiKey,
1220
+ baseUrl: prov.baseUrl,
1221
+ upstreamHttpVersion: prov.upstreamHttpVersion,
1222
+ ...(cursorFetch ? { fetch: cursorFetch } : {}),
1223
+ });
1212
1224
  if (liveResult.ok) {
1213
1225
  const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
1214
1226
  const result = available.length > 0 ? available : configured;
@@ -307,8 +307,8 @@ export function deriveEntry(
307
307
  if (isRouted) {
308
308
  // A routed model is NOT the native template: never inherit its context
309
309
  // window when /models omits context metadata (#992). Known metadata
310
- // restores exact values below; otherwise the strict-fields fallback
311
- // supplies the conservative 128k triple.
310
+ // restores exact values below; an enabled Context cap fills the gap;
311
+ // otherwise the strict-fields fallback supplies the 128k triple.
312
312
  if (!codexForwardNativeCapabilityAlias) {
313
313
  delete e.context_window;
314
314
  delete e.max_context_window;
@@ -1214,6 +1214,20 @@ interface RetainedCatalogSyncResult {
1214
1214
  skippedReason?: "desired_disabled";
1215
1215
  }
1216
1216
 
1217
+ /**
1218
+ * Catalog/cache commit overrides.
1219
+ *
1220
+ * An explicit `ocx sync` is also the refresh path for side profiles that consume
1221
+ * the OpenCodex catalog without injection (for example a custom `model_provider`
1222
+ * that routes to the proxy). In that mode the Codex integration toggle only
1223
+ * governs config/history injection; the catalog and models cache may still be
1224
+ * refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF
1225
+ * gate that otherwise protects a fully native home.
1226
+ */
1227
+ export interface CodexCatalogSyncOptions {
1228
+ allowWhenDesiredDisabled?: boolean;
1229
+ }
1230
+
1217
1231
  interface RetainedCatalogSyncWrite {
1218
1232
  readonly config: OcxConfig;
1219
1233
  readonly goModels: CatalogModel[];
@@ -1618,7 +1632,10 @@ function currentDisabledModelsForRestore(): Set<string> | null {
1618
1632
  }
1619
1633
  }
1620
1634
 
1621
- export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCatalogSyncResult> {
1635
+ export async function syncCatalogModels(
1636
+ config: OcxConfig,
1637
+ options?: CodexCatalogSyncOptions,
1638
+ ): Promise<RetainedCatalogSyncResult> {
1622
1639
  const owningCodexHome = getCodexHome();
1623
1640
  const preflightRead = readRetainedCatalogSync(config);
1624
1641
  if (preflightRead === null) {
@@ -1654,8 +1671,10 @@ export async function syncCatalogModels(config: OcxConfig): Promise<RetainedCata
1654
1671
  // evidence revalidation below cannot see that — intent lives in our config,
1655
1672
  // not in the catalog files — so the policy is re-read here, under K, right
1656
1673
  // before the only write. A lost race becomes the discriminated skip instead
1657
- // of a routed catalog/cache surviving a completed disable.
1658
- if (!shouldSyncCodexOnStart(loadConfig())) {
1674
+ // of a routed catalog/cache surviving a completed disable. An explicit
1675
+ // catalog-only sync opts out of that gate: the user asked for a refresh even
1676
+ // when injection is OFF, and the toggle only protects config/history writes.
1677
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) {
1659
1678
  return {
1660
1679
  added: 0,
1661
1680
  path: prepared.catalogPath,
@@ -1755,13 +1774,16 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
1755
1774
  export function invalidateCodexModelsCacheWithPermit(
1756
1775
  permit: CatalogWritePermit,
1757
1776
  owningCodexHome: string,
1777
+ options?: CodexCatalogSyncOptions,
1758
1778
  ): boolean {
1759
1779
  try {
1760
1780
  // This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
1761
1781
  // K before this rewrite runs, so the commit-path desired-state check cannot
1762
1782
  // cover it. A disable landing in that gap must not be overwritten by a
1763
1783
  // routed cache write — re-read intent under this permit, same as the commit.
1764
- if (!shouldSyncCodexOnStart(loadConfig())) return false;
1784
+ // The catalog-only sync override applies here too so an explicit refresh
1785
+ // keeps the cache consistent with the catalog it just wrote.
1786
+ if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
1765
1787
  const catalogPath = readCodexCatalogPath();
1766
1788
  if (!existsSync(catalogPath)) return false;
1767
1789
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
@@ -1803,11 +1825,11 @@ export function invalidateCodexModelsCacheWithPermit(
1803
1825
  }
1804
1826
  }
1805
1827
 
1806
- export function invalidateCodexModelsCache(): boolean {
1828
+ export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
1807
1829
  const owningCodexHome = getCodexHome();
1808
1830
  const outcome = withCatalogWriteSerialization(
1809
1831
  owningCodexHome,
1810
- permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome),
1832
+ permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
1811
1833
  );
1812
1834
  return outcome.kind === "completed" && outcome.value;
1813
1835
  }