@bitkyc08/opencodex 2.7.41 → 2.7.42

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.md +4 -0
  2. package/gui/dist/assets/index-Bl_VBGoI.js +65 -0
  3. package/gui/dist/assets/index-DfVGuN88.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +6 -0
  7. package/src/adapters/kiro-constants.ts +6 -2
  8. package/src/adapters/kiro-retry.ts +175 -10
  9. package/src/adapters/kiro.ts +172 -85
  10. package/src/adapters/mimo-free.ts +1 -0
  11. package/src/adapters/openai-chat.ts +30 -4
  12. package/src/adapters/openai-responses.ts +90 -12
  13. package/src/bridge.ts +91 -43
  14. package/src/claude/desktop-3p-paths.ts +84 -0
  15. package/src/claude/desktop-3p.ts +29 -2
  16. package/src/cli/access.ts +108 -0
  17. package/src/cli/account-auth.ts +223 -0
  18. package/src/cli/account.ts +9 -1
  19. package/src/cli/agent.ts +184 -0
  20. package/src/cli/combo.ts +119 -0
  21. package/src/cli/config-command.ts +145 -0
  22. package/src/cli/debug.ts +20 -8
  23. package/src/cli/doctor.ts +45 -8
  24. package/src/cli/help.ts +65 -13
  25. package/src/cli/index.ts +108 -7
  26. package/src/cli/integrations.ts +142 -0
  27. package/src/cli/models-runtime.ts +212 -0
  28. package/src/cli/models.ts +9 -10
  29. package/src/cli/observe.ts +117 -0
  30. package/src/cli/provider-runtime.ts +152 -0
  31. package/src/cli/provider.ts +23 -1
  32. package/src/cli/runtime-api.ts +325 -0
  33. package/src/cli/star-prompt.ts +3 -3
  34. package/src/cli/status.ts +17 -0
  35. package/src/cli/system-command.ts +112 -0
  36. package/src/codex/auth-api.ts +3 -2
  37. package/src/codex/catalog/aggregation.ts +113 -18
  38. package/src/codex/catalog/provider-fetch.ts +24 -13
  39. package/src/codex/catalog/sync.ts +20 -8
  40. package/src/codex/catalog.ts +2 -1
  41. package/src/codex/refresh.ts +10 -3
  42. package/src/codex/routing.ts +21 -32
  43. package/src/codex/sync.ts +17 -0
  44. package/src/config.ts +48 -0
  45. package/src/generated/jawcode-model-metadata.ts +2 -1
  46. package/src/grok/inject.ts +184 -4
  47. package/src/grok/status.ts +33 -0
  48. package/src/lib/retry-after.ts +55 -0
  49. package/src/lib/windows-elevation.ts +627 -0
  50. package/src/providers/openai-sidecar.ts +46 -2
  51. package/src/providers/registry.ts +52 -0
  52. package/src/server/auth-cors.ts +6 -0
  53. package/src/server/chat-completions.ts +6 -1
  54. package/src/server/claude-messages.ts +20 -1
  55. package/src/server/images.ts +14 -7
  56. package/src/server/management/agent-settings-routes.ts +10 -4
  57. package/src/server/management/combo-routes.ts +0 -1
  58. package/src/server/management/config-routes.ts +0 -1
  59. package/src/server/management/logs-usage-routes.ts +94 -0
  60. package/src/server/management/model-routes.ts +0 -1
  61. package/src/server/management/oauth-account-routes.ts +0 -1
  62. package/src/server/management/provider-routes.ts +0 -1
  63. package/src/server/management/shared.ts +0 -1
  64. package/src/server/management/system-routes.ts +27 -15
  65. package/src/server/management-api.ts +0 -1
  66. package/src/server/memory-watchdog.ts +54 -10
  67. package/src/server/request-log-conversation.ts +168 -0
  68. package/src/server/request-log.ts +122 -2
  69. package/src/server/responses/core.ts +76 -13
  70. package/src/server/responses/passthrough-error.ts +38 -13
  71. package/src/server/startup-action-control.ts +266 -15
  72. package/src/service.ts +512 -3
  73. package/src/storage/cleanup.ts +1538 -0
  74. package/src/storage/scanner.ts +4 -1
  75. package/src/types.ts +16 -0
  76. package/src/update/job.ts +229 -25
  77. package/src/usage/log.ts +39 -0
  78. package/src/web-search/loop.ts +8 -1
  79. package/gui/dist/assets/index-B2J4t3te.css +0 -1
  80. package/gui/dist/assets/index-BmvM6wRb.js +0 -65
@@ -38,6 +38,37 @@ export const openAiApiCollisionWarnings = new Set<string>();
38
38
 
39
39
  export const comboCatalogWarningSignatures = new Map<string, string>();
40
40
 
41
+ /**
42
+ * Why `deriveComboCatalogModel` rejected a configured combo (#484 / #516).
43
+ * Distinguishes unresolved/incomplete member metadata from a complete but empty
44
+ * modality intersection so diagnostics do not send operators to the wrong fix.
45
+ */
46
+ export type ComboCatalogOmissionReason =
47
+ | "incomplete_metadata"
48
+ | "incompatible_modalities";
49
+
50
+ /** Combos omitted from the catalog during the most recent `gatherRoutedModels` call (#484). */
51
+ export interface ComboCatalogOmission {
52
+ id: string;
53
+ targets: string[];
54
+ reason: ComboCatalogOmissionReason;
55
+ message: string;
56
+ }
57
+
58
+ let lastComboCatalogOmissions: ComboCatalogOmission[] = [];
59
+
60
+ export function clearLastComboCatalogOmissions(): void {
61
+ lastComboCatalogOmissions = [];
62
+ }
63
+
64
+ export function getLastComboCatalogOmissions(): readonly ComboCatalogOmission[] {
65
+ return lastComboCatalogOmissions;
66
+ }
67
+
68
+ export function replaceLastComboCatalogOmissions(items: readonly ComboCatalogOmission[]): void {
69
+ lastComboCatalogOmissions = [...items];
70
+ }
71
+
41
72
  export function intersectStrings(values: readonly string[][]): string[] {
42
73
  if (values.length === 0) return [];
43
74
  const rest = values.slice(1).map(value => new Set(value));
@@ -60,28 +91,50 @@ export function effectiveComboDefault(
60
91
  return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
61
92
  }
62
93
 
63
- export function deriveComboCatalogModel(
64
- id: string,
94
+ /**
95
+ * Classify a combo derivation failure. Returns `null` when the combo would catalog.
96
+ * Callers that already know derivation failed can map the reason into diagnostics.
97
+ */
98
+ export function comboCatalogOmissionReason(
65
99
  combo: NormalizedComboConfig,
66
100
  members: readonly CatalogModel[],
67
- ): CatalogModel | null {
68
- if (combo.targets.length === 0) return null;
69
- if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) return null;
70
- if (members.length !== combo.targets.length) return null;
101
+ ): ComboCatalogOmissionReason | null {
102
+ if (combo.targets.length === 0) return "incomplete_metadata";
103
+ if (new Set(combo.targets.map(targetKey)).size !== combo.targets.length) {
104
+ return "incomplete_metadata";
105
+ }
106
+ if (members.length !== combo.targets.length) return "incomplete_metadata";
71
107
  if (!members.every((member, index) => (
72
108
  `${member.provider}/${member.id}` === targetKey(combo.targets[index]!)
73
- ))) return null;
109
+ ))) {
110
+ return "incomplete_metadata";
111
+ }
74
112
  const contexts = members.map(member => member.contextWindow);
75
- if (contexts.some(value => typeof value !== "number" || value <= 0)) return null;
113
+ if (contexts.some(value => typeof value !== "number" || value <= 0)) {
114
+ return "incomplete_metadata";
115
+ }
116
+
117
+ const inputModalities = intersectStrings(
118
+ members.map(member => member.inputModalities ?? ["text"]),
119
+ );
120
+ if (inputModalities.length === 0) return "incompatible_modalities";
121
+ return null;
122
+ }
123
+
124
+ export function deriveComboCatalogModel(
125
+ id: string,
126
+ combo: NormalizedComboConfig,
127
+ members: readonly CatalogModel[],
128
+ ): CatalogModel | null {
129
+ if (comboCatalogOmissionReason(combo, members) !== null) return null;
76
130
 
77
131
  const inputModalities = intersectStrings(
78
132
  members.map(member => member.inputModalities ?? ["text"]),
79
133
  );
80
- if (inputModalities.length === 0) return null;
81
134
  const reasoningEfforts = intersectStrings(
82
135
  members.map(member => member.reasoningEfforts ?? []),
83
136
  );
84
- const contextWindow = Math.min(...contexts as number[]);
137
+ const contextWindow = Math.min(...members.map(member => member.contextWindow!));
85
138
  const maxInputTokens = Math.min(
86
139
  ...members.map(member => member.maxInputTokens ?? member.contextWindow!),
87
140
  );
@@ -136,20 +189,62 @@ export function comboCatalogWarningSignature(
136
189
  }).sort((a, b) => a.key.localeCompare(b.key)));
137
190
  }
138
191
 
139
- export function warnUncataloguedComboOnce(
192
+ export function comboCatalogOmissionDetail(reason: ComboCatalogOmissionReason): string {
193
+ return reason === "incompatible_modalities"
194
+ ? "members have no common input modalities"
195
+ : "member capabilities are incomplete";
196
+ }
197
+
198
+ /** One-line sync/CLI summary that respects the actual omission reason(s). */
199
+ export function summarizeComboCatalogOmissions(
200
+ omissions: readonly Pick<ComboCatalogOmission, "reason">[],
201
+ ): string {
202
+ const n = omissions.length;
203
+ const prefix = `${n} combo${n === 1 ? "" : "s"} omitted from the catalog`;
204
+ if (n === 0) return prefix + ".";
205
+ const reasons = new Set(omissions.map(item => item.reason));
206
+ if (reasons.size === 1) {
207
+ return `${prefix} because ${comboCatalogOmissionDetail([...reasons][0]!)}.`;
208
+ }
209
+ return `${prefix}.`;
210
+ }
211
+
212
+ export function buildComboCatalogOmission(
140
213
  id: string,
141
214
  combo: NormalizedComboConfig,
142
215
  members: readonly CatalogModel[],
143
- ): void {
144
- const signature = comboCatalogWarningSignature(combo, members);
145
- if (comboCatalogWarningSignatures.get(id) === signature) return;
146
- comboCatalogWarningSignatures.set(id, signature);
216
+ ): ComboCatalogOmission {
147
217
  const targets = combo.targets
148
218
  .map(target => safeCatalogWarningLabel(targetKey(target)))
149
219
  .sort((a, b) => a.localeCompare(b));
150
- console.warn(
151
- `[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because member capabilities are incomplete: ${targets.join(", ")}.`,
152
- );
220
+ const reason = comboCatalogOmissionReason(combo, members) ?? "incomplete_metadata";
221
+ return {
222
+ id,
223
+ targets,
224
+ reason,
225
+ message: `[opencodex] Combo "${safeCatalogWarningLabel(id)}" is omitted from the catalog because ${comboCatalogOmissionDetail(reason)}: ${targets.join(", ")}.`,
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Record a combo omitted from `/v1/models` + on-disk catalog and warn once per signature.
231
+ * Callers that need a race-free list (catalog sync) pass a local `sink` from the same
232
+ * gather invocation instead of reading process-global state afterward (#484 review).
233
+ */
234
+ export function warnUncataloguedComboOnce(
235
+ id: string,
236
+ combo: NormalizedComboConfig,
237
+ members: readonly CatalogModel[],
238
+ sink: ComboCatalogOmission[] = lastComboCatalogOmissions,
239
+ ): ComboCatalogOmission {
240
+ const omission = buildComboCatalogOmission(id, combo, members);
241
+ sink.push(omission);
242
+ const signature = comboCatalogWarningSignature(combo, members);
243
+ if (comboCatalogWarningSignatures.get(id) !== signature) {
244
+ comboCatalogWarningSignatures.set(id, signature);
245
+ console.warn(omission.message);
246
+ }
247
+ return omission;
153
248
  }
154
249
 
155
250
  export function exactComboCatalogSlugs(
@@ -47,11 +47,8 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
47
47
  import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
48
48
  import type { CatalogModel } from "./parsing";
49
49
  import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
50
- import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, warnUncataloguedComboOnce } from "./aggregation";
51
-
52
- type OcxProviderConfigWithReasoningSummaries = OcxProviderConfig & {
53
- modelSupportsReasoningSummaries?: Record<string, boolean>;
54
- };
50
+ import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
51
+ import type { ComboCatalogOmission } from "./aggregation";
55
52
 
56
53
  export type ProviderModelsApiItem = {
57
54
  id: string;
@@ -89,6 +86,13 @@ export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): n
89
86
  return typeof configured === "number" && configured > 0 ? configured : undefined;
90
87
  }
91
88
 
89
+ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined {
90
+ if (!prov) return undefined;
91
+ const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id);
92
+ if (explicit !== undefined) return explicit;
93
+ return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined;
94
+ }
95
+
92
96
  export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel {
93
97
  void name;
94
98
  const configuredCap = configuredContextWindow(prov, model.id);
@@ -104,10 +108,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
104
108
  }
105
109
  const reasoningEfforts = configuredReasoningEfforts(prov, model.id);
106
110
  const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort;
107
- const supportsReasoningSummaries = modelRecordValue(
108
- (prov as OcxProviderConfigWithReasoningSummaries).modelSupportsReasoningSummaries,
109
- model.id,
110
- );
111
+ const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id);
111
112
  const hinted = {
112
113
  ...model,
113
114
  ...(configuredCap !== undefined
@@ -464,7 +465,12 @@ export function filterCatalogVisibleModels(
464
465
  });
465
466
  }
466
467
 
467
- export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogModel[]> {
468
+ export async function gatherRoutedModels(
469
+ config: OcxConfig,
470
+ options?: { comboOmissions?: ComboCatalogOmission[] },
471
+ ): Promise<CatalogModel[]> {
472
+ // Per-invocation list: sync passes `comboOmissions` so overlapping gathers cannot race.
473
+ const localOmissions: ComboCatalogOmission[] = [];
468
474
  const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
469
475
  // Persisted provider entries can predate newer registry fields (noVisionModels,
470
476
  // modelInputModalities, ...). The ROUTER merges registry seeds at request time
@@ -542,15 +548,20 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
542
548
  .filter((member): member is CatalogModel => member !== undefined);
543
549
  const derived = deriveComboCatalogModel(id, combo, members);
544
550
  if (derived) all.push(derived);
545
- else warnUncataloguedComboOnce(id, combo, members);
551
+ else warnUncataloguedComboOnce(id, combo, members, localOmissions);
552
+ }
553
+ replaceLastComboCatalogOmissions(localOmissions);
554
+ if (options?.comboOmissions) {
555
+ options.comboOmissions.length = 0;
556
+ options.comboOmissions.push(...localOmissions);
546
557
  }
547
558
  all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
548
559
  // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
549
560
  // custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
550
561
  const enrichedByName = new Map(activeProviders);
551
562
  const customModels = (config.customModels ?? []).map(cm => {
552
- const rawProvider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
553
- const supportsReasoningSummaries = modelRecordValue(rawProvider?.modelSupportsReasoningSummaries, cm.modelId);
563
+ const rawProvider = config.providers[cm.provider];
564
+ const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId);
554
565
  const base: CatalogModel = {
555
566
  id: cm.modelId,
556
567
  provider: cm.provider,
@@ -37,7 +37,8 @@ import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSl
37
37
  import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
38
38
  import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
39
39
  import { filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
40
- import { comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
40
+ import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
41
+ import type { ComboCatalogOmission } from "./aggregation";
41
42
 
42
43
  export const MAX_SPAWN_AGENT_MODEL_OVERRIDES = 5;
43
44
 
@@ -293,6 +294,7 @@ export function resetCatalogRuntimeStateForTests(): void {
293
294
  comboCatalogWarningSignatures.clear();
294
295
  slugAliasCollisionWarnings.clear();
295
296
  comboMasqueradeCollisionWarnings.clear();
297
+ clearLastComboCatalogOmissions();
296
298
  clearModelCache();
297
299
  }
298
300
 
@@ -452,14 +454,20 @@ export function mergeCatalogEntriesForSync(
452
454
  return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode);
453
455
  }
454
456
 
455
- export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> {
457
+ export async function syncCatalogModels(config: OcxConfig): Promise<{
458
+ added: number;
459
+ path: string;
460
+ catalogWritten: boolean;
461
+ comboOmissions: ComboCatalogOmission[];
462
+ }> {
456
463
  const catalogPath = readCodexCatalogPath();
457
464
  const catalog = loadCatalogForSync(catalogPath);
458
- if (!catalog) return { added: 0, path: catalogPath };
465
+ if (!catalog) return { added: 0, path: catalogPath, catalogWritten: false, comboOmissions: [] };
459
466
 
460
467
  const template = findNativeTemplate(catalog);
461
468
 
462
- const goModels = await gatherRoutedModels(config);
469
+ const comboOmissions: ComboCatalogOmission[] = [];
470
+ const goModels = await gatherRoutedModels(config, { comboOmissions });
463
471
  try {
464
472
  // Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
465
473
  // (later syncs would otherwise overwrite it with featured-modified priorities).
@@ -493,7 +501,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num
493
501
  clampCatalogModelsToCodexSupport(catalog.models);
494
502
 
495
503
  atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n");
496
- return { added: goEntries.length, path: catalogPath };
504
+ return { added: goEntries.length, path: catalogPath, catalogWritten: true, comboOmissions };
497
505
  }
498
506
 
499
507
  export function restoreCodexCatalog(): { removed: number; kept: number; path: string } {
@@ -524,10 +532,11 @@ export function restoreCodexCatalog(): { removed: number; kept: number; path: st
524
532
  return { removed, kept: native.length, path: catalogPath };
525
533
  }
526
534
 
527
- export function invalidateCodexModelsCache(): void {
535
+ /** Force Codex's models_cache stale from the on-disk catalog. Returns whether a cache write occurred. */
536
+ export function invalidateCodexModelsCache(): boolean {
528
537
  try {
529
538
  const catalogPath = readCodexCatalogPath();
530
- if (!existsSync(catalogPath)) return;
539
+ if (!existsSync(catalogPath)) return false;
531
540
  const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
532
541
  const models = catalog.models ?? catalog;
533
542
  const wrapper = {
@@ -536,5 +545,8 @@ export function invalidateCodexModelsCache(): void {
536
545
  models,
537
546
  };
538
547
  atomicWriteFile(activeCodexModelsCachePath(), JSON.stringify(wrapper, null, 2) + "\n");
539
- } catch { /* best-effort */ }
548
+ return true;
549
+ } catch {
550
+ return false;
551
+ }
540
552
  }
@@ -6,6 +6,7 @@ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, v
6
6
  export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
7
7
  export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8
8
  export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
9
- export { deriveComboCatalogModel, exactComboCatalogSlugs, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList } from "./catalog/aggregation";
9
+ export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
10
+ export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
10
11
  export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";
11
12
  export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync";
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { invalidateCodexModelsCache, syncCatalogModels } from "./catalog";
3
+ import type { ComboCatalogOmission } from "./catalog/aggregation";
3
4
  import { CODEX_MODELS_CACHE_PATH } from "./paths";
4
5
  import { atomicWriteFile } from "../config";
5
6
  import type { OcxConfig } from "../types";
@@ -8,7 +9,9 @@ export interface CodexCatalogRefreshResult {
8
9
  added: number;
9
10
  path: string;
10
11
  catalogExists: boolean;
12
+ catalogWritten: boolean;
11
13
  cacheSynced: boolean;
14
+ comboOmissions: ComboCatalogOmission[];
12
15
  }
13
16
 
14
17
  interface RefreshDeps {
@@ -40,7 +43,11 @@ export async function refreshCodexModelCatalog(
40
43
  ): Promise<CodexCatalogRefreshResult> {
41
44
  const result = await deps.syncCatalogModels(config);
42
45
  const catalogExists = deps.existsSync(result.path);
43
- if (!catalogExists) return { ...result, catalogExists, cacheSynced: false };
44
- deps.invalidateCodexModelsCache();
45
- return { ...result, catalogExists, cacheSynced: true };
46
+ const catalogWritten = result.catalogWritten === true;
47
+ const comboOmissions = result.comboOmissions ?? [];
48
+ if (!catalogExists) {
49
+ return { ...result, catalogExists, catalogWritten: false, cacheSynced: false, comboOmissions };
50
+ }
51
+ const cacheSynced = deps.invalidateCodexModelsCache();
52
+ return { ...result, catalogExists, catalogWritten, cacheSynced, comboOmissions };
46
53
  }
@@ -305,6 +305,16 @@ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Parti
305
305
  return cooldownFields;
306
306
  }
307
307
 
308
+ /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */
309
+ export function resetCodexRoutingForManualSelection(accountId: string): void {
310
+ clearThreadAccountMap();
311
+ const current = upstreamHealth.get(accountId);
312
+ if (!current) return;
313
+ const preserved = preservedCooldownFields(current);
314
+ if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId);
315
+ else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
316
+ }
317
+
308
318
  export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
309
319
  const cooldownUntil = upstreamHealth.get(accountId)?.cooldownUntil;
310
320
  return typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now ? cooldownUntil : null;
@@ -491,21 +501,14 @@ function isUnknownUsage(usage: number): boolean {
491
501
  return usage >= CODEX_UNKNOWN_USAGE_SCORE;
492
502
  }
493
503
 
494
- // Round-robin among eligible unknown-quota candidates. `getEligiblePoolAccounts`
495
- // already returns a deterministic order (config order, main unshifted first) and
496
- // excludes the active id, so taking the first eligible unknown is a stable rotation
497
- // without any new per-account state.
498
- function pickNextUnknownAccount(config: OcxConfig, active: string, now: number): string | null {
499
- const eligible = getEligiblePoolAccounts(config, active, now)
500
- .filter(id => isUnknownUsage(computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id))));
501
- return eligible.length > 0 ? eligible[0]! : null;
502
- }
503
-
504
504
  function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): string {
505
505
  const threshold = config.autoSwitchThreshold ?? 80;
506
506
  if (threshold <= 0) return active;
507
507
  const quota = getAccountQuota(active);
508
508
  const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
509
+ // Unknown usage is not evidence that a user's explicit selection crossed the
510
+ // threshold. Wait for quota priming instead of rotating among guesses.
511
+ if (isUnknownUsage(activeUsage)) return active;
509
512
  if (activeUsage < threshold) return active;
510
513
  const best = pickLowerUsageAccount(config, active, activeUsage, now);
511
514
  if (best !== active) {
@@ -513,21 +516,6 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
513
516
  return best;
514
517
  }
515
518
 
516
- // Deadlock guard: active is over threshold but no candidate scored strictly
517
- // lower. When the active itself is unknown, every candidate is likely unknown
518
- // too (100 < 100 never fires), which pins the pool to one account whose real
519
- // usage we cannot see (e.g. quota never primed on WSL). Rotate to the next
520
- // eligible unknown so rotation is not stuck; known-but-saturated accounts are
521
- // intentionally left alone so a genuinely hot pool stays visible.
522
- if (isUnknownUsage(activeUsage)) {
523
- const next = pickNextUnknownAccount(config, active, now);
524
- if (next) {
525
- console.warn(`[codex-routing] quota unknown for active "${active}"; rotating to "${next}" (all candidates unknown, threshold=${threshold})`);
526
- setActiveCodexAccount(config, next);
527
- return next;
528
- }
529
- console.warn(`[codex-routing] quota unknown for active "${active}" and no eligible rotation target; staying put`);
530
- }
531
519
  return active;
532
520
  }
533
521
 
@@ -585,7 +573,7 @@ export function previewCodexAccountForRequest(
585
573
  getAccountQuota(entry.accountId),
586
574
  getPoolAccountPlan(config, entry.accountId),
587
575
  );
588
- if (usage >= threshold) {
576
+ if (!isUnknownUsage(usage) && usage >= threshold) {
589
577
  const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
590
578
  if (best !== entry.accountId) return best;
591
579
  }
@@ -609,7 +597,7 @@ export function previewCodexAccountForRequest(
609
597
  const threshold = config.autoSwitchThreshold ?? 80;
610
598
  if (threshold > 0) {
611
599
  const usage = computeCodexUsageScore(getAccountQuota(active), getPoolAccountPlan(config, active));
612
- if (usage >= threshold) {
600
+ if (!isUnknownUsage(usage) && usage >= threshold) {
613
601
  active = pickLowerUsageAccount(config, active, usage, now);
614
602
  }
615
603
  }
@@ -657,7 +645,7 @@ export function resolveCodexAccountForThreadDetailed(
657
645
  getAccountQuota(entry.accountId),
658
646
  getPoolAccountPlan(config, entry.accountId),
659
647
  );
660
- if (usage >= threshold) {
648
+ if (!isUnknownUsage(usage) && usage >= threshold) {
661
649
  const best = pickLowerUsageAccount(config, entry.accountId, usage, now);
662
650
  if (best !== entry.accountId) {
663
651
  setActiveCodexAccount(config, best);
@@ -807,12 +795,13 @@ export function recordCodexUpstreamOutcome(
807
795
  const hardCooldownUntil = getCodexAccountCooldownUntil(accountId, now) ?? undefined;
808
796
  // Soft avoid + affinity clears are part of failover. When threshold is 0, leave
809
797
  // sticky sessions alone (same as shouldFailover / applyFailureFailover no-ops).
810
- const failoverEnabled = (config.upstreamFailoverThreshold ?? 3) > 0;
798
+ const failoverThreshold = config.upstreamFailoverThreshold ?? 3;
811
799
  const consecutiveFailures = stale ? 1 : (current?.consecutiveFailures ?? 0) + 1;
800
+ const failoverReady = failoverThreshold > 0 && consecutiveFailures >= failoverThreshold;
812
801
  const escalationMs = CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS[
813
- Math.min(consecutiveFailures, CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length) - 1
802
+ Math.min(Math.max(consecutiveFailures - failoverThreshold, 0), CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS.length - 1)
814
803
  ]!;
815
- const softAvoidUntil = failoverEnabled
804
+ const softAvoidUntil = failoverReady
816
805
  ? Math.max(
817
806
  getCodexAccountSoftAvoidUntil(accountId, now) ?? 0,
818
807
  now + escalationMs,
@@ -831,7 +820,7 @@ export function recordCodexUpstreamOutcome(
831
820
  // thread is still pinned to the FAILING account — a late failure from account A
832
821
  // must not delete a newer healthy binding to account B (race: T→A, A fails,
833
822
  // T→B, late A failure must not delete B's mapping).
834
- if (failoverEnabled && meta.threadId) {
823
+ if (failoverReady && meta.threadId) {
835
824
  const bound = threadAccountMap.get(meta.threadId);
836
825
  if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId);
837
826
  }
package/src/codex/sync.ts CHANGED
@@ -4,15 +4,18 @@ import { refreshCodexModelCatalog } from "./refresh";
4
4
  import { applyProxyEnv, loadConfig } from "../config";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { collectOrcaCodexHomeDiagnostic } from "./home";
7
+ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation";
7
8
 
8
9
  export interface CodexSyncResult {
9
10
  ok: boolean;
10
11
  added: number;
11
12
  catalogPath: string | null;
12
13
  catalogExists: boolean;
14
+ catalogWritten: boolean;
13
15
  cacheSynced: boolean;
14
16
  message: string;
15
17
  warning?: string;
18
+ comboOmissions?: ComboCatalogOmission[];
16
19
  projectConfigWarnings?: ProjectCodexConfigWarning[];
17
20
  projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[];
18
21
  }
@@ -59,6 +62,7 @@ export async function syncModelsToCodex(
59
62
  added: 0,
60
63
  catalogPath: null,
61
64
  catalogExists: false,
65
+ catalogWritten: false,
62
66
  cacheSynced: false,
63
67
  message: result.message,
64
68
  };
@@ -69,22 +73,33 @@ export async function syncModelsToCodex(
69
73
  let catalogPath: string | null = null;
70
74
  let catalogPathForInjection: string | null | undefined;
71
75
  let catalogExists = false;
76
+ let catalogWritten = false;
72
77
  let cacheSynced = false;
73
78
  let warning: string | undefined;
79
+ let comboOmissions: ComboCatalogOmission[] = [];
74
80
 
75
81
  try {
76
82
  const cat = await deps.refreshCodexModelCatalog(config);
77
83
  added = cat.added;
78
84
  catalogExists = cat.catalogExists;
85
+ catalogWritten = cat.catalogWritten;
79
86
  cacheSynced = cat.cacheSynced;
80
87
  catalogPathForInjection = cat.catalogExists ? cat.path : null;
81
88
  catalogPath = catalogPathForInjection;
89
+ comboOmissions = cat.comboOmissions ?? [];
82
90
  if (cat.added > 0) {
83
91
  log?.log(` + ${cat.added} models appended to Codex catalog (${cat.path})`);
84
92
  } else if (!cat.catalogExists) {
85
93
  warning = "catalog sync skipped: no Codex catalog source found; keeping Codex's native catalog.";
86
94
  log?.error(warning);
87
95
  }
96
+ if (comboOmissions.length > 0) {
97
+ // Individual omission lines already went through console.warn during gather;
98
+ // keep a single summary on the sync logger to avoid duplicate stderr noise.
99
+ const summary = summarizeComboCatalogOmissions(comboOmissions);
100
+ log?.error(summary);
101
+ warning = warning ? `${warning} ${summary}` : summary;
102
+ }
88
103
  } catch (e) {
89
104
  warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`;
90
105
  log?.error(warning);
@@ -99,9 +114,11 @@ export async function syncModelsToCodex(
99
114
  added,
100
115
  catalogPath,
101
116
  catalogExists,
117
+ catalogWritten,
102
118
  cacheSynced,
103
119
  message: result.message,
104
120
  ...(warning ? { warning } : {}),
121
+ ...(comboOmissions.length > 0 ? { comboOmissions } : {}),
105
122
  ...(projectConfigWarnings.length > 0 ? {
106
123
  projectConfigWarnings,
107
124
  projectConfigGrouped: groupProjectCodexConfigWarningsByPath(projectConfigWarnings),
package/src/config.ts CHANGED
@@ -11,11 +11,13 @@ import {
11
11
  isWirePinnedModel,
12
12
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
13
13
  OPENAI_PROVIDER_TIER_VERSION,
14
+ REASONING_SUMMARY_DELIVERY_VALUES,
14
15
  type OcxConfig,
15
16
  type OcxProviderConfig,
16
17
  } from "./types";
17
18
  import { isCanonicalOpenAiForwardProvider } from "./providers/openai-tiers";
18
19
  import { parseDesktopProfile } from "./claude/desktop-profile";
20
+ import { modelRecordValue } from "./reasoning-effort";
19
21
 
20
22
  let _atomicSeq = 0;
21
23
 
@@ -442,6 +444,34 @@ export function booleanRecordConfigError(value: unknown, field: string): string
442
444
  return null;
443
445
  }
444
446
 
447
+ const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);
448
+
449
+ export function reasoningSummaryDeliveryRecordConfigError(
450
+ value: unknown,
451
+ supportsReasoningSummaries: unknown,
452
+ field = "modelReasoningSummaryDelivery",
453
+ ): string | null {
454
+ if (value === undefined) return null;
455
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
456
+ const prototype = Object.getPrototypeOf(value);
457
+ if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`;
458
+
459
+ const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null
460
+ && supportsReasoningSummaries && typeof supportsReasoningSummaries === "object"
461
+ ? supportsReasoningSummaries as Record<string, boolean>
462
+ : undefined;
463
+ for (const [key, entry] of Object.entries(value)) {
464
+ if (!key.trim()) return `${field} keys must be nonblank model ids`;
465
+ if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) {
466
+ return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`;
467
+ }
468
+ if (modelRecordValue(supports, key) === false) {
469
+ return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`;
470
+ }
471
+ }
472
+ return null;
473
+ }
474
+
445
475
  /**
446
476
  * Validate a provider's per-model wire override map (#404).
447
477
  *
@@ -605,6 +635,17 @@ const configSchema = z.object({
605
635
  message: reasoningSummariesError,
606
636
  });
607
637
  }
638
+ const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
639
+ (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery,
640
+ (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries,
641
+ );
642
+ if (reasoningSummaryDeliveryError) {
643
+ ctx.addIssue({
644
+ code: "custom",
645
+ path: ["providers", name, "modelReasoningSummaryDelivery"],
646
+ message: reasoningSummaryDeliveryError,
647
+ });
648
+ }
608
649
  const defaultMaxOutputError = positiveIntegerConfigError(
609
650
  (provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens,
610
651
  "defaultMaxOutputTokens",
@@ -820,6 +861,13 @@ function schemaDiagnosticsError(error: z.ZodError): string {
820
861
  return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid";
821
862
  }
822
863
 
864
+ /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */
865
+ export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
866
+ const result = configSchema.safeParse(value);
867
+ if (result.success) return { ok: true, config: result.data as OcxConfig };
868
+ return { ok: false, error: schemaDiagnosticsError(result.error) };
869
+ }
870
+
823
871
  export function readConfigDiagnostics(): ConfigDiagnostics {
824
872
  const configPath = getConfigPath();
825
873
  if (!existsSync(configPath)) {
@@ -29,7 +29,8 @@ const PROVIDER_ALIASES: Record<string, string> = {
29
29
  "gemini-antigravity": "google",
30
30
  "moonshot": "moonshot",
31
31
  "minimax": "minimax",
32
- "minimax-cn": "minimax"
32
+ "minimax-cn": "minimax",
33
+ "zhipu-bigmodel": "zai"
33
34
  } as const;
34
35
 
35
36
  type Row = readonly [id: string, contextWindow?: number | null, maxTokens?: number | null, input?: string | null, reasoning?: 0 | 1 | null, wireModelId?: string | null, costInput?: number | null, costOutput?: number | null, costCacheRead?: number | null, costCacheWrite?: number | null];