@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
package/src/config.ts CHANGED
@@ -60,6 +60,7 @@ import {
60
60
  OPENAI_PROVIDER_TIER_VERSION,
61
61
  pinnedWireAdapter,
62
62
  REASONING_SUMMARY_DELIVERY_VALUES,
63
+ UPSTREAM_HTTP_VERSION_VALUES,
63
64
  type OcxClaudeCodeConfig,
64
65
  type OcxConfig,
65
66
  type OcxApiKeyEntry,
@@ -734,6 +735,12 @@ const providerConfigSchema = z.object({
734
735
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
735
736
  preserveResponsesReasoningContent: z.boolean().optional(),
736
737
  allowPrivateNetwork: z.boolean().optional(),
738
+ // The management API accepts `null` as "clear this", so a config written before the POST
739
+ // canonicalization below can hold one on disk. Rejecting it here would send the operator
740
+ // through invalid-config recovery for a value the API told them was fine.
741
+ upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
742
+ .nullish()
743
+ .transform(value => value ?? undefined),
737
744
  noStructuredOutputModels: z.array(z.string().min(1))
738
745
  .transform(normalizeNonBlankStringArray)
739
746
  .optional(),
@@ -906,6 +913,20 @@ export function apiKeyTransportConfigError(
906
913
  return null;
907
914
  }
908
915
 
916
+ /**
917
+ * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by
918
+ * the management write path (providerManagementConfigError / PATCH) so it can never disagree
919
+ * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and
920
+ * a value the loader rejects is rejected at write time too.
921
+ */
922
+ export function upstreamHttpVersionConfigError(value: unknown): string | null {
923
+ if (value === undefined || value === null) return null;
924
+ if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) {
925
+ return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear';
926
+ }
927
+ return null;
928
+ }
929
+
909
930
  export function positiveIntegerRecordConfigError(value: unknown, field: string): string | null {
910
931
  if (value === undefined) return null;
911
932
  if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
@@ -1997,12 +2018,30 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod
1997
2018
  if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) {
1998
2019
  delete normalized.subagentEffort;
1999
2020
  }
2021
+ // A hand-authored config never passes through the management validator, so coerce here too.
2022
+ // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would
2023
+ // otherwise reach the resolver unchecked.
2024
+ if (Object.hasOwn(normalized, "classifierModel")) {
2025
+ const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : "";
2026
+ if (value.length > 0) normalized.classifierModel = value;
2027
+ else delete normalized.classifierModel;
2028
+ }
2029
+ if (Object.hasOwn(normalized, "classifierFallbacks")) {
2030
+ const raw = normalized.classifierFallbacks;
2031
+ const kept = Array.isArray(raw)
2032
+ ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim())
2033
+ : [];
2034
+ if (kept.length > 0) normalized.classifierFallbacks = kept;
2035
+ else delete normalized.classifierFallbacks;
2036
+ }
2000
2037
  return normalized as OcxConfig["claudeCode"];
2001
2038
  }
2002
2039
 
2003
- function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig {
2004
- const rawEffort = rawClaudeSubagentEffort(rawParsed);
2005
- if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config;
2040
+ function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig {
2041
+ // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid,
2042
+ // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized.
2043
+ // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is.
2044
+ if (!config.claudeCode) return config;
2006
2045
  return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) };
2007
2046
  }
2008
2047
 
@@ -2172,6 +2211,26 @@ export function loadConfig(): OcxConfig {
2172
2211
  warnDegradedAgentTaskRecovery(parsed);
2173
2212
  return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2174
2213
  }
2214
+ // Still failing, but if every complaint is about one or more named entries
2215
+ // in an independent section, drop exactly those and keep the rest. Falling
2216
+ // back to defaults here would silently retire the operator's providers,
2217
+ // keys and prices over a mistake in one routing profile.
2218
+ const salvaged = salvageConfigCandidate(merged, retryResult.error);
2219
+ if (salvaged) {
2220
+ {
2221
+ warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues);
2222
+ const config = normalizeApiKeyIds(salvaged.parsed);
2223
+ warnDegradedHostname(parsed, config);
2224
+ warnDegradedApiKeys(parsed, config);
2225
+ warnDegradedCodexAccountPriorities(parsed, config);
2226
+ warnDegradedClaudeSubagentEffort(parsed);
2227
+ warnDegradedNativeSubagentConfig(parsed, config);
2228
+ warnDegradedCodexAccountPicker(parsed);
2229
+ warnDegradedUpstreamHostCircuitThreshold(parsed);
2230
+ warnDegradedAgentTaskRecovery(parsed);
2231
+ return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
2232
+ }
2233
+ }
2175
2234
  // Merge couldn't fix it — truly broken config
2176
2235
  warnAndBackupInvalidConfig(configPath, result.error);
2177
2236
  return getDefaultConfig();
@@ -2450,11 +2509,31 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
2450
2509
  return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed);
2451
2510
  }
2452
2511
 
2453
- const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
2512
+ const merged = mergeConfigDefaults(parsed);
2513
+ const retryResult = configSchema.safeParse(merged);
2454
2514
  if (retryResult.success) {
2455
2515
  return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed);
2456
2516
  }
2457
2517
 
2518
+ // #1785: one invalid routing profile must not make diagnostics report the built-in
2519
+ // defaults AS the config, because a later config write persists those defaults over the
2520
+ // operator's providers, keys and prices.
2521
+ //
2522
+ // The failure is still reported. `source` stays "fallback" and `error` keeps the real
2523
+ // schema message -- diagnostics is the surface that tells callers the file is invalid,
2524
+ // and every consumer that must refuse an invalid config (provider reload, catalog sync,
2525
+ // cost reconcile, codex admission) gates on exactly those two fields. Only `config`
2526
+ // changes: it carries the salvaged document instead of factory defaults, so a caller
2527
+ // that ignores the error and writes it back preserves what the operator configured.
2528
+ const salvaged = salvageConfigCandidate(merged, retryResult.error);
2529
+ if (salvaged) {
2530
+ return {
2531
+ config: normalizeApiKeyIds(salvaged.parsed),
2532
+ source: "fallback",
2533
+ error: schemaDiagnosticsError(result.error),
2534
+ };
2535
+ }
2536
+
2458
2537
  return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
2459
2538
  } catch {
2460
2539
  return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
@@ -3428,6 +3507,194 @@ function warnConfigRepaired(configPath: string, error: z.ZodError): void {
3428
3507
  console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`);
3429
3508
  }
3430
3509
 
3510
+ /**
3511
+ * Sections whose entries are independent of one another, so one bad entry is
3512
+ * safe to drop without changing what the rest mean.
3513
+ *
3514
+ * Both are validated entry-by-entry in the `superRefine` above, which raises
3515
+ * every finding as a *document*-level issue. That is what made a single routing
3516
+ * candidate naming a disabled provider discard the operator's whole config —
3517
+ * all eleven providers, every API key, and the entire `modelCosts` table —
3518
+ * while the proxy carried on serving from built-in defaults and reporting
3519
+ * healthy.
3520
+ */
3521
+ const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const;
3522
+
3523
+ /**
3524
+ * Drop just the named entries a parse failure blamed, so the rest of the
3525
+ * document survives.
3526
+ *
3527
+ * Returns `null` when the failure was not confined to those sections — the
3528
+ * caller then keeps its existing behaviour rather than guessing.
3529
+ *
3530
+ * The whole entry goes, not the individual offending candidate. A routing
3531
+ * profile that quietly loses one candidate still routes, just not where the
3532
+ * operator said it should, and a policy that silently changed shape is a worse
3533
+ * outcome than one that is plainly absent. Absent is also the loud option: a
3534
+ * dry-run against it answers `unknown_profile`, which — paired with the warning
3535
+ * this emits — points at the real mistake.
3536
+ */
3537
+ function dropInvalidConfigSections(
3538
+ parsed: unknown,
3539
+ error: z.ZodError,
3540
+ ): { candidate: Record<string, unknown>; dropped: string[] } | null {
3541
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3542
+
3543
+ const doomed = new Map<string, Set<string>>();
3544
+ for (const issue of error.issues) {
3545
+ if (isUnsalvageableIssue(issue)) return null;
3546
+ const [section, id] = issue.path;
3547
+ if (typeof section !== "string" || typeof id !== "string") return null;
3548
+ if (!(SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return null;
3549
+ // A complaint about the container itself ("combos must be an object") is
3550
+ // not about one entry, so there is nothing selective to drop.
3551
+ if (issue.path.length < 2) return null;
3552
+ let ids = doomed.get(section);
3553
+ if (!ids) doomed.set(section, ids = new Set());
3554
+ ids.add(id);
3555
+ }
3556
+ if (doomed.size === 0) return null;
3557
+
3558
+ const candidate: Record<string, unknown> = { ...(parsed as Record<string, unknown>) };
3559
+ const dropped: string[] = [];
3560
+ for (const [section, ids] of doomed) {
3561
+ const current = candidate[section];
3562
+ if (!current || typeof current !== "object" || Array.isArray(current)) return null;
3563
+ const kept: Record<string, unknown> = {};
3564
+ for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
3565
+ if (ids.has(key)) dropped.push(`${section}.${key}`);
3566
+ else kept[key] = value;
3567
+ }
3568
+ candidate[section] = kept;
3569
+ }
3570
+ return dropped.length > 0 ? { candidate, dropped } : null;
3571
+ }
3572
+
3573
+ /**
3574
+ * Salvage until the document parses, not just once.
3575
+ *
3576
+ * One pass is not enough because the sections depend on each other: routing
3577
+ * profiles are validated against the combo map, so dropping an invalid combo can
3578
+ * expose a profile that referenced it. A single-pass salvage sees that second
3579
+ * failure and gives up, discarding the whole config -- the exact outcome this
3580
+ * code exists to prevent.
3581
+ *
3582
+ * `rawDocument` is the operator's document before defaults were merged in. When
3583
+ * supplied, the same entries are deleted from it too, so a diagnostics caller can
3584
+ * still tell an absent optional setting from one we injected.
3585
+ */
3586
+
3587
+ /**
3588
+ * Findings that must never be salvaged away.
3589
+ *
3590
+ * Salvage removes the entry a finding blamed, which is right for an ordinary
3591
+ * validation mistake and wrong for a namespace collision: the collision is a
3592
+ * *relationship* between a combo/profile and a Codex account selector, and it is
3593
+ * reported on the combo. Dropping that combo makes the document parse and quietly
3594
+ * admits the account selector the schema just refused, turning a hard admission
3595
+ * boundary into a config that loads. Refuse the whole document instead.
3596
+ */
3597
+ const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [
3598
+ CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR,
3599
+ ];
3600
+
3601
+ function isUnsalvageableIssue(issue: z.ZodIssue): boolean {
3602
+ return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message));
3603
+ }
3604
+ function salvageConfigCandidate(
3605
+ merged: unknown,
3606
+ initialError: z.ZodError,
3607
+ rawDocument?: unknown,
3608
+ ): {
3609
+ candidate: Record<string, unknown>;
3610
+ rawCandidate: unknown;
3611
+ parsed: OcxConfig;
3612
+ dropped: string[];
3613
+ issues: z.ZodIssue[];
3614
+ } | null {
3615
+ let candidate: unknown = merged;
3616
+ let rawCandidate: unknown = rawDocument;
3617
+ let error = initialError;
3618
+ const dropped: string[] = [];
3619
+ const issues: z.ZodIssue[] = [];
3620
+ // Bounded by construction: every pass must remove at least one entry, and there
3621
+ // are only so many entries to remove.
3622
+ const budget = countSalvageableEntries(merged) + 1;
3623
+ for (let pass = 0; pass < budget; pass++) {
3624
+ const step = dropInvalidConfigSections(candidate, error);
3625
+ if (!step || step.dropped.length === 0) return null;
3626
+ dropped.push(...step.dropped);
3627
+ issues.push(...error.issues);
3628
+ candidate = step.candidate;
3629
+ rawCandidate = deleteEntryPaths(rawCandidate, step.dropped);
3630
+ const result = configSchema.safeParse(candidate);
3631
+ if (result.success) {
3632
+ return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues };
3633
+ }
3634
+ error = result.error;
3635
+ }
3636
+ return null;
3637
+ }
3638
+
3639
+ function countSalvageableEntries(document: unknown): number {
3640
+ if (!document || typeof document !== "object" || Array.isArray(document)) return 0;
3641
+ let total = 0;
3642
+ for (const section of SALVAGEABLE_CONFIG_SECTIONS) {
3643
+ const value = (document as Record<string, unknown>)[section];
3644
+ if (value && typeof value === "object" && !Array.isArray(value)) {
3645
+ total += Object.keys(value as Record<string, unknown>).length;
3646
+ }
3647
+ }
3648
+ return total;
3649
+ }
3650
+
3651
+ /** Delete `section.id` entries from a copy of the raw document. */
3652
+ function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown {
3653
+ if (!document || typeof document !== "object" || Array.isArray(document)) return document;
3654
+ const next: Record<string, unknown> = { ...(document as Record<string, unknown>) };
3655
+ for (const entryPath of entryPaths) {
3656
+ const separator = entryPath.indexOf(".");
3657
+ if (separator <= 0) continue;
3658
+ const section = entryPath.slice(0, separator);
3659
+ const id = entryPath.slice(separator + 1);
3660
+ const container = next[section];
3661
+ if (!container || typeof container !== "object" || Array.isArray(container)) continue;
3662
+ const kept: Record<string, unknown> = { ...(container as Record<string, unknown>) };
3663
+ delete kept[id];
3664
+ next[section] = kept;
3665
+ }
3666
+ return next;
3667
+ }
3668
+
3669
+ /**
3670
+ * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches
3671
+ * the log unredacted. Static section names stay readable -- they are the part that
3672
+ * tells the operator where to look.
3673
+ */
3674
+ function redactEntryPath(entryPath: string): string {
3675
+ const separator = entryPath.indexOf(".");
3676
+ if (separator <= 0) return redactSecretString(entryPath);
3677
+ return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1));
3678
+ }
3679
+
3680
+ function redactIssuePath(path: readonly PropertyKey[]): string {
3681
+ return path
3682
+ .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment))))
3683
+ .join(".");
3684
+ }
3685
+
3686
+ function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void {
3687
+ if (warnedConfigFallbacks.has(configPath)) return;
3688
+ warnedConfigFallbacks.add(configPath);
3689
+ const reasons = issues
3690
+ .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`)
3691
+ .join("; ");
3692
+ console.error(
3693
+ `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. `
3694
+ + "Everything else in your config, including providers and modelCosts, is preserved.",
3695
+ );
3696
+ }
3697
+
3431
3698
  export function readPidFileValue(): number | null {
3432
3699
  try {
3433
3700
  return parsePidFile(readFileSync(getPidPath(), "utf-8"));