@bitkyc08/opencodex 2.49.0 → 2.50.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 (69) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/claude/inbound.ts +17 -5
  7. package/src/cli/account-api.ts +18 -3
  8. package/src/cli/account-auth.ts +8 -1
  9. package/src/cli/account-extended.ts +2 -1
  10. package/src/cli/account.ts +1 -0
  11. package/src/cli/capabilities.ts +15 -1
  12. package/src/cli/index.ts +5 -1
  13. package/src/cli/models-runtime.ts +8 -3
  14. package/src/cli/observe.ts +13 -3
  15. package/src/clients/config-export/zcode.ts +24 -0
  16. package/src/codex/account-runtime-state.ts +6 -1
  17. package/src/codex/account-store.ts +72 -9
  18. package/src/codex/account-usability.ts +3 -2
  19. package/src/codex/auth-api.ts +107 -23
  20. package/src/codex/auth-context.ts +21 -0
  21. package/src/codex/catalog/parsing.ts +23 -0
  22. package/src/codex/catalog/provider-fetch.ts +71 -2
  23. package/src/codex/catalog/sync.ts +14 -0
  24. package/src/codex/inject.ts +3 -2
  25. package/src/codex/quota-auto-refresh.ts +6 -1
  26. package/src/codex/quota.ts +54 -8
  27. package/src/combos/index.ts +2 -0
  28. package/src/combos/resolve.ts +52 -0
  29. package/src/config.ts +58 -0
  30. package/src/generated/compatibility-version.json +72 -60
  31. package/src/lib/errors.ts +8 -0
  32. package/src/lib/privacy.ts +25 -0
  33. package/src/oauth/health.ts +47 -12
  34. package/src/oauth/index.ts +46 -8
  35. package/src/oauth/token-guardian.ts +32 -6
  36. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  37. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  38. package/src/providers/quota.ts +15 -0
  39. package/src/providers/registry.ts +1 -1
  40. package/src/server/auth-cors.ts +6 -0
  41. package/src/server/chat-completions.ts +4 -4
  42. package/src/server/chat-native.ts +10 -1
  43. package/src/server/claude-messages.ts +5 -5
  44. package/src/server/images.ts +2 -2
  45. package/src/server/index.ts +25 -2
  46. package/src/server/management/logs-usage-routes.ts +4 -1
  47. package/src/server/management/model-rows.ts +16 -1
  48. package/src/server/management/oauth-account-routes.ts +6 -2
  49. package/src/server/management/provider-routes.ts +9 -2
  50. package/src/server/management/request-history-routes.ts +4 -2
  51. package/src/server/management/route-registry.ts +5 -4
  52. package/src/server/management/shared.ts +66 -3
  53. package/src/server/management-api.ts +1 -1
  54. package/src/server/request-decompress.ts +91 -3
  55. package/src/server/request-log.ts +10 -0
  56. package/src/server/responses/codex-ws-wire.ts +1 -1
  57. package/src/server/responses/compact.ts +8 -2
  58. package/src/server/responses/context-overflow.ts +11 -0
  59. package/src/server/responses/core.ts +144 -38
  60. package/src/server/responses/policy-fallback.ts +6 -2
  61. package/src/server/search.ts +2 -2
  62. package/src/service.ts +92 -7
  63. package/src/types/accounts.ts +18 -0
  64. package/src/types/config.ts +36 -0
  65. package/src/types/provider.ts +56 -0
  66. package/src/types.ts +4 -0
  67. package/src/web-search/ollama-executor.ts +127 -0
  68. package/src/web-search/passthrough-bridge.ts +761 -0
  69. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -407,7 +407,20 @@ export function flushQuotaObservationsForTests(): Promise<void> {
407
407
  return pendingObservation;
408
408
  }
409
409
 
410
- export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
410
+ /** Wire marker shared by Spark-family models, whose upstream limit family is model-specific. */
411
+ const SPARK_MODEL_MARKER = "codex-spark";
412
+ /**
413
+ * Custom-window label for the Spark 5h window. The WHAM parser and the response-header path
414
+ * must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
415
+ */
416
+ const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
417
+
418
+ /** True when the routed model belongs to the Spark family, which carries its own rate limit. */
419
+ function isCodexSparkModel(modelId: string | undefined): boolean {
420
+ return typeof modelId === "string" && modelId.includes(SPARK_MODEL_MARKER);
421
+ }
422
+
423
+ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
411
424
  const primaryRaw = headers.get("x-codex-primary-used-percent");
412
425
  const secondaryRaw = headers.get("x-codex-secondary-used-percent");
413
426
  const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
@@ -430,6 +443,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
430
443
  // it into weeklyPercent both discards the real weekly reading and leaves the account looking
431
444
  // exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
432
445
  // already does for the WHAM payload — the two parsers must not disagree about the same data.
446
+ // One more attribution layer (#4122): on a Spark-family model response the sub-day primary is
447
+ // the MODEL-SPECIFIC limit, not an account window. Filing it as the account short tuple made
448
+ // one pool account display a 5h bar its identically-limited peers did not have, and fed a
449
+ // model limit to the account-policy readers (main-account hard lock, five-hour auto-refresh).
433
450
  const primaryIsShort = isExplicitShortWindowMinutes(primaryWindowMinutes);
434
451
 
435
452
  if (primaryIsMonthly) {
@@ -446,10 +463,21 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
446
463
  if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
447
464
  }
448
465
  } else if (primaryIsShort) {
449
- if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
450
- if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
451
- const minutes = windowMinutes_(primaryWindowMinutes);
452
- if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
466
+ if (isCodexSparkModel(options?.modelId)) {
467
+ if (primaryPercent !== undefined) {
468
+ const sparkWindow: { label: string; percent: number; resetAt?: number } = {
469
+ label: SPARK_SHORT_WINDOW_LABEL,
470
+ percent: primaryPercent,
471
+ };
472
+ if (primaryResetAt !== undefined) sparkWindow.resetAt = primaryResetAt;
473
+ quota.customWindows = [sparkWindow];
474
+ }
475
+ } else {
476
+ if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
477
+ if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
478
+ const minutes = windowMinutes_(primaryWindowMinutes);
479
+ if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
480
+ }
453
481
  // The burst window vacates the primary slot, so the weekly reading is the secondary — which
454
482
  // is where it was all along. Without this the true weekly value is silently dropped.
455
483
  if (secondaryPercent !== undefined) {
@@ -480,13 +508,31 @@ export function applyAccountQuotaFromUpstreamHeaders(
480
508
  headers: Headers,
481
509
  writerGeneration = captureConfigGeneration(),
482
510
  mainWriter?: MainQuotaWriter,
511
+ options?: { modelId?: string },
483
512
  ): void {
484
- const quota = parseUpstreamQuotaHeaders(headers);
513
+ const quota = parseUpstreamQuotaHeaders(headers, options);
485
514
  if (!quota) return;
486
515
  const policyQuota = [
487
516
  "x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent",
488
517
  ].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota);
489
- setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota);
518
+ // A header-observed Spark window is a partial update against the WHAM-recorded custom windows:
519
+ // merge by label so the weekly Spark entry survives, and hydrate first so the first call in a
520
+ // process does not merge against an empty map. The merged list goes only to the legacy
521
+ // snapshot — the identity-bound policy evidence keeps exactly what this response said.
522
+ let legacyQuota = quota;
523
+ if (quota.customWindows !== undefined) {
524
+ hydrateAccountQuotasFromDisk();
525
+ const existing = accountQuota.get(accountId)?.customWindows;
526
+ if (existing !== undefined) {
527
+ const incoming = new Map(quota.customWindows.map(window => [window.label, window]));
528
+ const merged = existing.map(window => incoming.get(window.label) ?? window);
529
+ for (const window of quota.customWindows) {
530
+ if (!existing.some(entry => entry.label === window.label)) merged.push(window);
531
+ }
532
+ legacyQuota = { ...quota, customWindows: merged };
533
+ }
534
+ }
535
+ setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
490
536
  }
491
537
 
492
538
  export function updateAccountQuota(
@@ -812,7 +858,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
812
858
  });
813
859
  const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
814
860
  for (const [label, window] of [
815
- ["GPT-5.3-Codex-Spark 5h", sparkShort],
861
+ [SPARK_SHORT_WINDOW_LABEL, sparkShort],
816
862
  ["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
817
863
  ] as const) {
818
864
  const percent = normalizeUsagePercent(window?.used_percent);
@@ -27,9 +27,11 @@ export {
27
27
  noteComboSuccess,
28
28
  pickComboTarget,
29
29
  pickComboTargetWithWait,
30
+ quotaInactiveReason,
30
31
  tryPickComboModel,
31
32
  UnknownComboError,
32
33
  type ComboPick,
34
+ type QuotaInactiveReason,
33
35
  } from "./resolve";
34
36
  export {
35
37
  clearComboTargetCooldowns,
@@ -92,6 +92,58 @@ export function cachedProviderQuotaIsExhausted(
92
92
  return false;
93
93
  }
94
94
 
95
+ /**
96
+ * Why a catalog row is offered but cannot currently serve a request (#1711).
97
+ *
98
+ * Only one reason exists today. It is a string rather than a boolean so a later cause — a
99
+ * cooldown, a revoked key — can be told apart by a consumer that already reads the field.
100
+ */
101
+ export type QuotaInactiveReason = "no_credit";
102
+
103
+ /**
104
+ * `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence
105
+ * (#1711), otherwise undefined.
106
+ *
107
+ * This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the
108
+ * Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted
109
+ * without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked
110
+ * inactive on the harsher rule would contradict the router, which would still happily send the
111
+ * request.
112
+ *
113
+ * Three rules carry the correctness, all inherited rather than restated:
114
+ *
115
+ * - A target the operator has removed or disabled is not usable and is not evidence either way;
116
+ * it drops out before the vote. If nothing is left, the row is unavailable for an operator
117
+ * reason rather than a quota one, so this returns undefined.
118
+ * - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped
119
+ * quota, and a provider-level summary cannot veto it.
120
+ * - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute
121
+ * window, and a null reading ends the vote rather than counting as evidence, so an unprobed
122
+ * provider is never marked inactive.
123
+ *
124
+ * "Every" is the bar on purpose: one target that can still serve makes the row serviceable, which
125
+ * is exactly what the combo loop concludes at request time.
126
+ */
127
+ export function quotaInactiveReason(
128
+ config: OcxConfig,
129
+ targets: readonly { provider: string }[],
130
+ now = Date.now(),
131
+ ): QuotaInactiveReason | undefined {
132
+ const usable = targets.filter(target => {
133
+ if (!Object.hasOwn(config.providers, target.provider)) return false;
134
+ const provider = config.providers[target.provider];
135
+ return !!provider && provider.disabled !== true;
136
+ });
137
+ if (usable.length === 0) return undefined;
138
+ for (const target of usable) {
139
+ const provider = config.providers[target.provider]!;
140
+ if (isCanonicalOpenAiForwardProvider(provider)) return undefined;
141
+ const quota = getCachedProviderQuota(target.provider, now);
142
+ if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined;
143
+ }
144
+ return "no_credit";
145
+ }
146
+
95
147
  function smoothWeightedIndex(
96
148
  targets: Required<OcxComboTarget>[],
97
149
  state: SelectionState,
package/src/config.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
74
74
  OPENAI_PROVIDER_TIER_VERSION,
75
75
  pinnedWireAdapter,
76
+ PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
76
77
  UPSTREAM_HTTP_VERSION_VALUES,
77
78
  type OcxClaudeCodeConfig,
78
79
  type OcxConfig,
@@ -497,6 +498,47 @@ export function requestPacingConfigError(value: unknown): string | null {
497
498
  return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
498
499
  }
499
500
 
501
+ /**
502
+ * Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
503
+ * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
504
+ * leave the bridge disarmed while the operator believes they enabled it. `endpoint` is only
505
+ * shape-checked here; `planPassthroughWebSearchBridge` re-validates the origin before any key
506
+ * is sent to it, because config validation is not an authorization boundary.
507
+ */
508
+ const providerWebSearchBridgeSchema = z.object({
509
+ enabled: z.boolean().optional(),
510
+ backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
511
+ maxSearches: z.number().int().min(1).max(10).optional(),
512
+ timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
513
+ endpoint: z.string().min(1).optional(),
514
+ }).strict();
515
+
516
+ export function providerWebSearchBridgeConfigError(value: unknown): string | null {
517
+ if (value === undefined) return null;
518
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
519
+ return "webSearchBridge must be a plain object";
520
+ }
521
+ const parsed = providerWebSearchBridgeSchema.safeParse(value);
522
+ if (!parsed.success) {
523
+ return "webSearchBridge accepts only enabled (boolean), backend "
524
+ + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
525
+ + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
526
+ }
527
+ const endpoint = parsed.data.endpoint;
528
+ if (endpoint !== undefined) {
529
+ let url: URL;
530
+ try {
531
+ url = new URL(endpoint);
532
+ } catch {
533
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
534
+ }
535
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
536
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+
500
542
  const fastWireSchema = z.object({
501
543
  kind: z.string(),
502
544
  canonicalToWire: z.record(z.string().trim(), z.string().trim()),
@@ -600,6 +642,10 @@ const providerConfigSchema = z.object({
600
642
  repairInvalidIds: z.boolean().optional(),
601
643
  }).strict().optional(),
602
644
  responsesSnapshotRepair: z.boolean().optional(),
645
+ // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
646
+ // bridge block must never send an operator through invalid-config recovery for an opt-in
647
+ // feature that is off by default. The management write boundary still rejects it loudly.
648
+ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
603
649
  xaiResponsesXSearch: z.boolean().optional(),
604
650
  xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
605
651
  }).passthrough();
@@ -1100,6 +1146,9 @@ const configSchema = z.object({
1100
1146
  // candidates are rejected explicitly by remoteGuiConfigError below.
1101
1147
  hub: hubConfigSchema.optional().catch(undefined),
1102
1148
  remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
1149
+ // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
1150
+ // emailMaskingEnabled then falls back to masked, which is also what an absent block means.
1151
+ privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
1103
1152
  // A malformed present client block must remain diagnosable from raw config and
1104
1153
  // fail closed through src/client/state.ts; unrelated provider state still loads.
1105
1154
  client: clientConnectionSchema.optional().catch(undefined),
@@ -1118,6 +1167,15 @@ const configSchema = z.object({
1118
1167
  .min(0)
1119
1168
  .optional()
1120
1169
  .catch(undefined),
1170
+ // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
1171
+ // rather than failing the parse, matching the outbound guard above: a malformed number must
1172
+ // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
1173
+ // `.catch`, and because a config object can be built without this schema at all — but in
1174
+ // `resolveInboundBodyLimitBytes()`, which every reader goes through.
1175
+ maxInboundBodyBytes: z.number().int()
1176
+ .min(0)
1177
+ .optional()
1178
+ .catch(undefined),
1121
1179
  appOwnedMemoryBudgetMb: z.number().int()
1122
1180
  .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
1123
1181
  .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)