@bitkyc08/opencodex 2.11.0 → 2.11.1

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 (92) hide show
  1. package/gui/dist/assets/{index-Bk-PN-70.css → index-BA1dgo4q.css} +1 -1
  2. package/gui/dist/assets/index-DDZpgzKk.js +70 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +6 -4
  5. package/src/adapters/anthropic.ts +10 -2
  6. package/src/adapters/cursor/native-exec-common.ts +6 -2
  7. package/src/adapters/google-antigravity-replay.ts +3 -2
  8. package/src/adapters/google-antigravity-wire.ts +38 -6
  9. package/src/adapters/google.ts +57 -10
  10. package/src/adapters/openai-chat.ts +19 -2
  11. package/src/claude/model-info.ts +1 -1
  12. package/src/cli/account-auth.ts +3 -1
  13. package/src/cli/account-catalog-refresh.ts +14 -0
  14. package/src/cli/account-extended.ts +15 -1
  15. package/src/cli/codex-shim-readiness.ts +69 -0
  16. package/src/cli/combo.ts +5 -0
  17. package/src/cli/config-command.ts +33 -4
  18. package/src/cli/doctor.ts +21 -0
  19. package/src/cli/export-command.ts +6 -6
  20. package/src/cli/help.ts +6 -6
  21. package/src/cli/index.ts +22 -5
  22. package/src/cli/models-runtime.ts +13 -1
  23. package/src/cli/provider.ts +7 -0
  24. package/src/clients/config-export.ts +67 -5
  25. package/src/codex/account-lifecycle.ts +99 -10
  26. package/src/codex/auth-api.ts +222 -34
  27. package/src/codex/catalog/account-models.ts +9 -4
  28. package/src/codex/catalog/aggregation.ts +41 -8
  29. package/src/codex/catalog/bundled.ts +54 -22
  30. package/src/codex/catalog/effort.ts +47 -20
  31. package/src/codex/catalog/kinds.ts +2 -0
  32. package/src/codex/catalog/metadata.ts +70 -18
  33. package/src/codex/catalog/native-models.ts +7 -0
  34. package/src/codex/catalog/parsing.ts +29 -11
  35. package/src/codex/catalog/provider-fetch.ts +335 -50
  36. package/src/codex/catalog/sync.ts +588 -126
  37. package/src/codex/catalog-refresh-status.ts +87 -0
  38. package/src/codex/catalog-write-serialization.ts +2 -1
  39. package/src/codex/catalog.ts +4 -3
  40. package/src/codex/convergence-types.ts +1 -1
  41. package/src/codex/convergence.ts +190 -52
  42. package/src/codex/custom-model-catalog-migration.ts +176 -0
  43. package/src/codex/features.ts +79 -1
  44. package/src/codex/history-job.ts +81 -1
  45. package/src/codex/history-lock.ts +2 -1
  46. package/src/codex/history-provider.ts +4 -3
  47. package/src/codex/inject.ts +56 -12
  48. package/src/codex/model-cache.ts +50 -10
  49. package/src/codex/transition-state.ts +10 -2
  50. package/src/codex/user-identity.ts +110 -2
  51. package/src/combos/index.ts +3 -0
  52. package/src/combos/types.ts +75 -9
  53. package/src/config.ts +26 -12
  54. package/src/generated/{jawcode-model-metadata.ts → model-metadata.ts} +12 -10
  55. package/src/integrations/registry.ts +7 -0
  56. package/src/integrations/writer.ts +1 -1
  57. package/src/lib/pinned-http.ts +40 -9
  58. package/src/lib/process-control.ts +4 -1
  59. package/src/lib/provider-outbound.ts +42 -9
  60. package/src/oauth/cursor.ts +25 -5
  61. package/src/oauth/index.ts +59 -45
  62. package/src/providers/antigravity-models.ts +115 -3
  63. package/src/providers/context-cap.ts +13 -5
  64. package/src/providers/model-discovery-limits.ts +16 -0
  65. package/src/providers/model-discovery.ts +14 -15
  66. package/src/providers/provider-id-rewrite.ts +29 -0
  67. package/src/providers/quota.ts +93 -19
  68. package/src/providers/registry.ts +16 -5
  69. package/src/providers/slug-codec.ts +13 -6
  70. package/src/reasoning-effort.ts +22 -0
  71. package/src/router.ts +0 -20
  72. package/src/routing/history/indexer.ts +54 -39
  73. package/src/routing/quota.ts +77 -56
  74. package/src/server/index.ts +32 -7
  75. package/src/server/management/combo-routes.ts +43 -19
  76. package/src/server/management/config-routes.ts +115 -20
  77. package/src/server/management/model-routes.ts +10 -7
  78. package/src/server/management/model-rows.ts +6 -2
  79. package/src/server/management/oauth-account-routes.ts +12 -0
  80. package/src/server/management/provider-routes.ts +114 -40
  81. package/src/server/management/routing-profile-routes.ts +0 -22
  82. package/src/server/management-api.ts +7 -25
  83. package/src/server/responses/core.ts +3 -3
  84. package/src/server/responses/policy-fallback.ts +152 -0
  85. package/src/server/responses.ts +3 -2
  86. package/src/types.ts +15 -2
  87. package/src/usage/cost.ts +0 -0
  88. package/src/vision/describe.ts +3 -1
  89. package/src/vision/index.ts +17 -6
  90. package/src/vision/reasoning.ts +55 -0
  91. package/src/web-search/parse.ts +15 -3
  92. package/gui/dist/assets/index-BynIEIV-.js +0 -70
@@ -25,10 +25,11 @@ import {
25
25
  import { removeCredential } from "../../oauth/store";
26
26
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
27
27
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
28
- import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
28
+ import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
29
+ import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
29
30
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
30
31
  import { deriveProviderPresets } from "../../providers/derive";
31
- import { providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
32
+ import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
32
33
  import {
33
34
  extractModelEnvelopeRows,
34
35
  extractProviderModelItems,
@@ -506,19 +507,33 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
506
507
  // credential resolution/network access for providers such as Antigravity.
507
508
  return jsonResponse({ applicable: false, reason: "static_catalog", latencyMs: 0 });
508
509
  }
509
- const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth");
510
- const apiKey = await resolveModelsAuthToken(name, prov);
510
+ const { buildModelsRequest, getValidAccessTokenSnapshot, resolveModelsAuthToken } = await import("../../oauth");
511
+ const antigravity = effectiveGoogleMode(name, prov) === "cloud-code-assist";
512
+ const snapshot = antigravity
513
+ ? await getValidAccessTokenSnapshot(name).catch(() => undefined)
514
+ : undefined;
515
+ const apiKey = snapshot?.accessToken ?? await resolveModelsAuthToken(name, prov);
511
516
  if (prov.authMode === "oauth" && !apiKey) {
512
517
  return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
513
518
  }
514
- const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
519
+ const project = prov.project ?? snapshot?.projectId;
520
+ if (antigravity && !project) {
521
+ return jsonResponse({ ok: false, latencyMs: 0, error: "Antigravity project unavailable — re-run `ocx login google-antigravity`" });
522
+ }
523
+ const { method, url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
515
524
  const discovery = resolveProviderModelDiscovery(name, prov);
516
525
  const started = Date.now();
517
526
  try {
518
- const res = await providerOutboundGet(name, prov, modelsUrl, {
519
- headers,
520
- signal: AbortSignal.timeout(8000),
521
- });
527
+ const res = method === "POST"
528
+ ? await providerOutboundPost(name, prov, modelsUrl, {
529
+ headers,
530
+ body: JSON.stringify({ project }),
531
+ signal: AbortSignal.timeout(8000),
532
+ })
533
+ : await providerOutboundGet(name, prov, modelsUrl, {
534
+ headers,
535
+ signal: AbortSignal.timeout(8000),
536
+ });
522
537
  const latencyMs = Date.now() - started;
523
538
  const redirectError = await providerRedirectError(res, modelsUrl);
524
539
  if (redirectError) {
@@ -534,7 +549,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
534
549
  } catch {
535
550
  // Best-effort release for non-conforming response streams.
536
551
  }
537
- return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
552
+ return jsonResponse({ ok: false, latencyMs, error: `upstream model discovery returned ${res.status}` });
538
553
  }
539
554
  const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
540
555
  if (!bounded.ok) {
@@ -542,20 +557,26 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
542
557
  ok: false,
543
558
  latencyMs,
544
559
  error: bounded.reason === "response_too_large"
545
- ? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit`
546
- : "upstream /models returned invalid JSON",
560
+ ? `upstream model discovery exceeded the ${discovery.maxResponseBytes}-byte response limit`
561
+ : "upstream model discovery returned invalid JSON",
547
562
  });
548
563
  }
564
+ const ccaModels = antigravity ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels) : undefined;
565
+ if (antigravity && !ccaModels) {
566
+ return jsonResponse({ ok: false, latencyMs, error: "upstream CCA model discovery returned an unexpected shape" });
567
+ }
549
568
  // OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
550
569
  // as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
551
570
  // connectivity-only count because it is not an authoritative catalog source.
552
571
  const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
553
572
  ? bounded.value as Record<string, unknown>
554
573
  : undefined;
555
- const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data)
574
+ const extracted = ccaModels
575
+ ? undefined
576
+ : Array.isArray(bounded.value) || Array.isArray(record?.data)
556
577
  ? extractProviderModelItems(bounded.value, discovery)
557
578
  : extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
558
- if (!extracted.ok) {
579
+ if (extracted && !extracted.ok) {
559
580
  return jsonResponse({
560
581
  ok: false,
561
582
  latencyMs,
@@ -564,7 +585,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
564
585
  : "upstream /models returned an unexpected shape",
565
586
  });
566
587
  }
567
- const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
588
+ const models = ccaModels?.length ?? ("items" in extracted! ? extracted!.items.length : extracted!.rows.length);
568
589
  return jsonResponse({
569
590
  ok: true,
570
591
  latencyMs,
@@ -615,13 +636,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
615
636
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
616
637
  if (fallbackDefault) config.defaultProvider = fallbackDefault;
617
638
  delete config.providers[name];
639
+ const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
640
+ const droppedCustomModels = dropProviderCustomModels(config, name);
618
641
  setProviderContextCap(config, name, false);
619
642
  save(config);
620
643
  reconcileLiveStateStores();
621
644
  const { clearModelCache: clearCache } = await import("../../codex/model-cache");
622
645
  clearCache(name);
623
646
  const catalogRefresh = await convergeCodexCatalog();
624
- return jsonResponse({ success: true, ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}), catalogRefresh });
647
+ return jsonResponse({
648
+ success: true,
649
+ ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}),
650
+ ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
651
+ catalogRefresh,
652
+ });
625
653
  }
626
654
 
627
655
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -629,8 +657,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
629
657
  }
630
658
 
631
659
  if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
632
- let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
633
- try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
660
+ let rawBody: unknown;
661
+ try { rawBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
662
+ // Reject non-object payloads (e.g. `{"provider": true}` or a bare array) before any
663
+ // property access, with the route's consistent 400 response.
664
+ if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider-context-caps body must be a plain object" }, 400);
665
+ const body = rawBody as { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
634
666
  const { saveConfigPreservingClaudeCode: save } = await import("../../config");
635
667
  const { clearModelCache } = await import("../../codex/model-cache");
636
668
  const respond = (catalogRefresh: Awaited<ReturnType<typeof convergeCodexCatalog>>) => jsonResponse({
@@ -641,21 +673,78 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
641
673
  catalogRefresh,
642
674
  });
643
675
 
644
- // Branch 1: set the global cap value and re-point every enabled provider to it.
676
+ // Reject malformed and mixed payloads before branch selection: `provider` and `enabled`
677
+ // must appear together with their expected types, and provider updates cannot be
678
+ // combined with `setAll` (which would otherwise be silently ignored).
679
+ const hasProviderFields = Object.hasOwn(body, "provider") || Object.hasOwn(body, "enabled");
680
+ if (hasProviderFields) {
681
+ if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
682
+ return jsonResponse({ error: "provider and enabled are required together" }, 400);
683
+ }
684
+ if (Object.hasOwn(body, "setAll")) {
685
+ return jsonResponse({ error: "setAll cannot be combined with provider updates" }, 400);
686
+ }
687
+ }
688
+
689
+ // Branch 1: per-provider toggle (checked first: a per-provider request may carry an
690
+ // explicit `value`, which must never fall through to the global-value branch). Enable
691
+ // writes the current global default unless an explicit per-provider value is supplied;
692
+ // that value is never copied to other providers.
693
+ if (typeof body.provider === "string" && typeof body.enabled === "boolean") {
694
+ const provider = body.provider.trim();
695
+ if (!isValidProviderName(provider)) {
696
+ return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
697
+ }
698
+ if (!hasOwnProvider(config.providers, provider)) {
699
+ return jsonResponse({ error: "unknown provider" }, 404);
700
+ }
701
+ // Validate a supplied per-provider value before mutating anything: it must be a
702
+ // finite number, and after flooring it must still be >= 1 — a value like 0.5 would
703
+ // otherwise floor to 0 and silently fall back to the global default.
704
+ if (body.value !== undefined && (typeof body.value !== "number" || !Number.isFinite(body.value))) {
705
+ return jsonResponse({ error: "value must be a positive number" }, 400);
706
+ }
707
+ const perProviderValue = typeof body.value === "number" ? Math.floor(body.value) : undefined;
708
+ if (perProviderValue !== undefined && perProviderValue < 1) {
709
+ return jsonResponse({ error: "value must be a positive number" }, 400);
710
+ }
711
+ setProviderContextCap(config, provider, body.enabled, perProviderValue);
712
+ save(config);
713
+ reconcileLiveStateStores();
714
+ clearModelCache(provider);
715
+ const catalogRefresh = await convergeCodexCatalog();
716
+ return respond(catalogRefresh);
717
+ }
718
+
719
+ // Branch 2: set the global cap value. When `setAll` accompanies it (dashboard "apply to
720
+ // every routed provider" toggle), re-point every enabled provider; otherwise keep each
721
+ // provider's own cap value and only change the default for future toggles.
645
722
  if (body.value !== undefined) {
646
- if (typeof body.value !== "number" || !Number.isFinite(body.value) || body.value <= 0) {
723
+ // Same normalization as the per-provider branch: floor before validating so a value
724
+ // that floors to zero is rejected rather than silently stored.
725
+ if (typeof body.value !== "number" || !Number.isFinite(body.value)) {
647
726
  return jsonResponse({ error: "value must be a positive number" }, 400);
648
727
  }
728
+ const normalizedValue = Math.floor(body.value);
729
+ if (normalizedValue < 1) {
730
+ return jsonResponse({ error: "value must be a positive number" }, 400);
731
+ }
732
+ if (body.setAll !== undefined && typeof body.setAll !== "boolean") {
733
+ return jsonResponse({ error: "setAll must be a boolean" }, 400);
734
+ }
649
735
  const affected = Object.keys(providerContextCaps(config));
650
- setGlobalContextCapValue(config, body.value);
736
+ const applyToAll = body.setAll === true;
737
+ setGlobalContextCapValue(config, normalizedValue, applyToAll);
651
738
  save(config);
652
739
  reconcileLiveStateStores();
653
- for (const provider of affected) clearModelCache(provider);
740
+ if (applyToAll) {
741
+ for (const provider of affected) clearModelCache(provider);
742
+ }
654
743
  const catalogRefresh = await convergeCodexCatalog();
655
744
  return respond(catalogRefresh);
656
745
  }
657
746
 
658
- // Branch 2: enable/clear the cap for every provider at once.
747
+ // Branch 3: enable/clear the cap for every provider at once.
659
748
  if (body.setAll !== undefined) {
660
749
  if (typeof body.setAll !== "boolean") {
661
750
  return jsonResponse({ error: "setAll must be a boolean" }, 400);
@@ -670,23 +759,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
670
759
  return respond(catalogRefresh);
671
760
  }
672
761
 
673
- // Branch 3: existing per-provider toggle (enable writes the current global value).
674
- if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
675
- return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
676
- }
677
- const provider = body.provider.trim();
678
- if (!isValidProviderName(provider)) {
679
- return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
680
- }
681
- if (!hasOwnProvider(config.providers, provider)) {
682
- return jsonResponse({ error: "unknown provider" }, 404);
683
- }
684
- setProviderContextCap(config, provider, body.enabled);
685
- save(config);
686
- reconcileLiveStateStores();
687
- clearModelCache(provider);
688
- const catalogRefresh = await convergeCodexCatalog();
689
- return respond(catalogRefresh);
762
+ // Unrecognized payload: reject rather than silently succeeding.
763
+ return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
690
764
  }
691
765
 
692
766
  // Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
@@ -20,10 +20,6 @@ import { candidateCapabilityEvidence } from "../../routing/capability";
20
20
  import { policyCandidateHealthEvidence } from "../../routing/health";
21
21
  import { quotaEvidenceForCandidate } from "../../routing/quota";
22
22
  import { costEvidenceForCandidate } from "../../routing/cost";
23
- import { providerCodexAccountMode } from "../../providers/registry";
24
- import { getEffectiveActiveCodexAccountId } from "../../codex/routing";
25
- import { getAccountSet } from "../../oauth/store";
26
- import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
27
23
  import { saveConfigPreservingClaudeCode } from "../../config";
28
24
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
29
25
  import { isPlainRecord } from "./shared";
@@ -115,24 +111,6 @@ function assembleCandidateEvidence(
115
111
  quota: quotaEvidenceForCandidate({
116
112
  provider: candidate.provider,
117
113
  model: candidate.model,
118
- ...(candidate.provider === OPENAI_CODEX_PROVIDER_ID
119
- && providerCodexAccountMode(
120
- OPENAI_CODEX_PROVIDER_ID,
121
- config.providers[OPENAI_CODEX_PROVIDER_ID],
122
- ) === "pool"
123
- ? (() => {
124
- const codexAccountId = getEffectiveActiveCodexAccountId(config);
125
- return {
126
- codexAccountId,
127
- codexAccountPlan: codexAccountId
128
- ? config.codexAccounts?.find(account => account.id === codexAccountId)?.plan
129
- : undefined,
130
- };
131
- })()
132
- : {}),
133
- accountRef: candidate.provider === "anthropic"
134
- ? getAccountSet("anthropic")?.activeAccountId
135
- : undefined,
136
114
  }),
137
115
  cost: costEvidenceForCandidate({
138
116
  provider: candidate.provider,
@@ -77,6 +77,7 @@ export type { ManagementApiDeps } from "./management/context";
77
77
  import { fetchAllModels } from "./management/shared";
78
78
  import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch";
79
79
  import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-types";
80
+ import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status";
80
81
  import { managementBodyTooLargeResponse } from "./management/body";
81
82
 
82
83
  // installed npm version instead of a stale hardcode.
@@ -88,28 +89,6 @@ export const VERSION = (() => {
88
89
  }
89
90
  })();
90
91
 
91
- function isCatalogDisposition(value: unknown): value is CatalogDisposition {
92
- if (!value || typeof value !== "object" || !("status" in value)) return false;
93
- const disposition = value as Record<string, unknown>;
94
- if (disposition.status === "committed") {
95
- return typeof disposition.changed === "boolean"
96
- && typeof disposition.degraded === "boolean"
97
- && Array.isArray(disposition.notices)
98
- && disposition.notices.every(notice => notice === "provider-auth" || notice === "provider-network" || notice === "fallback");
99
- }
100
- if (disposition.status === "skipped") {
101
- return ["not-requested", "catalog-unavailable", "busy", "stale", "refused"].includes(String(disposition.reason))
102
- && typeof disposition.retryable === "boolean";
103
- }
104
- if (disposition.status === "failed") {
105
- return ["provider-auth", "provider-network", "disk"].includes(String(disposition.reason))
106
- && (disposition.phase === "gather" || disposition.phase === "commit")
107
- && typeof disposition.retryable === "boolean"
108
- && typeof disposition.partialWrite === "boolean";
109
- }
110
- return false;
111
- }
112
-
113
92
  const managementConvergenceBindings = new WeakMap<object, Readonly<{
114
93
  factory: (config: Readonly<OcxConfig>) => ConvergeCodex;
115
94
  converge: ConvergeCodex;
@@ -153,10 +132,13 @@ export async function handleManagementAPI(
153
132
  const { createCatalogConvergeRequest } = await import("../codex/catalog-admission");
154
133
  convergenceInvoked = true;
155
134
  const outcome = await managementConvergeCodex(createCatalogConvergeRequest({ deadlineMs: 1_000 }));
156
- if (!outcome || outcome.kind !== "catalog-only" || !isCatalogDisposition(outcome.catalogRefresh)) {
135
+ const catalogRefresh = outcome?.kind === "catalog-only"
136
+ ? normalizeCatalogDisposition(outcome.catalogRefresh)
137
+ : null;
138
+ if (!catalogRefresh) {
157
139
  throw new TypeError("Catalog convergence returned an invalid outcome.");
158
140
  }
159
- return outcome.catalogRefresh;
141
+ return catalogRefresh;
160
142
  } catch {
161
143
  return {
162
144
  status: "failed",
@@ -263,7 +245,7 @@ export async function handleManagementAPI(
263
245
  const { ConfigMutationLockError } = await import("../config");
264
246
  const { CodexCredentialRefreshLockTimeoutError } = await import("../codex/account-store");
265
247
  try {
266
- return await handleCodexAuthAPI(req, url, config);
248
+ return await handleCodexAuthAPI(req, url, config, convergeCodexCatalog);
267
249
  } catch (error) {
268
250
  // Credential writers remap ConfigMutationLockError to CodexCredentialRefreshLockTimeoutError;
269
251
  // treat both as the same retryable busy response.
@@ -40,7 +40,6 @@ import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxP
40
40
  import {
41
41
  forceRefreshOAuthAccessSnapshot,
42
42
  getOAuthCredentialApiBaseUrl,
43
- getOAuthCredentialProjectId,
44
43
  getValidAccessTokenForAccount,
45
44
  getValidAccessTokenSnapshot,
46
45
  type OAuthAccessSnapshot,
@@ -1677,9 +1676,10 @@ async function handleResponsesInner(
1677
1676
  parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
1678
1677
  }
1679
1678
  // Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
1680
- // CCA envelope; the server injects only the bare token, so pull project from the credential.
1679
+ // CCA envelope. Keep it paired with the token snapshot so an account rotation cannot mix
1680
+ // a fresh token with project metadata re-read from a different credential generation.
1681
1681
  if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
1682
- const projectId = getOAuthCredentialProjectId(route.providerName);
1682
+ const projectId = resolved.projectId;
1683
1683
  if (projectId) route.provider = { ...route.provider, project: projectId };
1684
1684
  }
1685
1685
  }
@@ -0,0 +1,152 @@
1
+ import { comboFailureDecision } from "../../combos/failover";
2
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
3
+ import { readJsonRequestBody } from "../request-decompress";
4
+ import { finishRequestAttempt, type RequestLogContext } from "../request-log";
5
+ import type { OcxConfig } from "../../types";
6
+ import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace";
7
+ import { handleResponses as handleResponsesCore } from "./core";
8
+
9
+ type CoreHandler = typeof handleResponsesCore;
10
+ type CoreOptions = Parameters<CoreHandler>[3];
11
+
12
+ export interface PolicyFallbackDeps {
13
+ runCore?: CoreHandler;
14
+ }
15
+
16
+ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">): string {
17
+ return `${candidate.provider}\u0000${candidate.model}`;
18
+ }
19
+
20
+ /**
21
+ * Rank the remaining candidates from the ORIGINAL policy trace. The initial
22
+ * decision stays immutable; fallback execution belongs in attempts[], not in a
23
+ * rewritten decision trace.
24
+ */
25
+ export function rankPolicyFallbackCandidates(
26
+ trace: RouteDecisionTraceV1,
27
+ tried: ReadonlySet<string>,
28
+ ): RouteCandidateTrace[] {
29
+ return trace.candidates
30
+ .map((candidate, index) => ({ candidate, index }))
31
+ .filter(({ candidate }) =>
32
+ candidate.eligible
33
+ && candidate.exclusions.length === 0
34
+ && !tried.has(candidateKey(candidate)))
35
+ .sort((left, right) => {
36
+ const scoreDelta = (right.candidate.score?.total ?? Number.NEGATIVE_INFINITY)
37
+ - (left.candidate.score?.total ?? Number.NEGATIVE_INFINITY);
38
+ return scoreDelta || left.index - right.index;
39
+ })
40
+ .map(({ candidate }) => candidate);
41
+ }
42
+
43
+ function requestWithCandidate(
44
+ req: Request,
45
+ rawBody: Record<string, unknown>,
46
+ candidate: Pick<RouteCandidateTrace, "provider" | "model">,
47
+ ): Request {
48
+ const headers = new Headers(req.headers);
49
+ headers.delete("content-encoding");
50
+ headers.delete("content-length");
51
+ headers.set("content-type", "application/json");
52
+ return new Request(req.url, {
53
+ method: req.method,
54
+ headers,
55
+ body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }),
56
+ signal: req.signal,
57
+ });
58
+ }
59
+
60
+ function errorCodeFromText(text: string): string | undefined {
61
+ if (!text) return undefined;
62
+ try {
63
+ const payload = JSON.parse(text) as { error?: { code?: unknown; type?: unknown }; code?: unknown };
64
+ const candidate = payload.error?.code ?? payload.error?.type ?? payload.code;
65
+ return typeof candidate === "string" ? candidate : undefined;
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal): Promise<boolean> {
72
+ if (response.status < 400 || signal?.aborted) return false;
73
+ try {
74
+ const inspected = await readBoundedResponseBody(response.clone(), { signal });
75
+ const text = inspected.displaySafe ? inspected.text : "";
76
+ return comboFailureDecision(response.status, text, { code: errorCodeFromText(text) }) === "hop";
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ function isPolicyDecision(trace: RouteDecisionTraceV1 | undefined): trace is RouteDecisionTraceV1 {
83
+ return trace?.routeKind === "policy" && !!trace.profile;
84
+ }
85
+
86
+ /** Finalize the failed physical attempt so the retry receives a fresh attempt row. */
87
+ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): void {
88
+ const attempt = logCtx.activeAttempt;
89
+ if (attempt) {
90
+ const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
91
+ finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
92
+ }
93
+ delete logCtx.activeAttempt;
94
+ delete logCtx.activeAttemptStartedAt;
95
+ delete logCtx.usage;
96
+ delete logCtx.usageFromBridge;
97
+ delete logCtx.upstreamError;
98
+ delete logCtx.terminalHttpStatus;
99
+ delete logCtx.terminalIncompleteReason;
100
+ }
101
+
102
+ /**
103
+ * Run a Responses request and, only for an explicitly selected policy profile,
104
+ * hop to the next eligible policy candidate after a retryable pre-success
105
+ * failure. The initial policy trace remains the canonical selection evidence;
106
+ * physical retries continue to accumulate in the existing request attempts.
107
+ */
108
+ export async function handleResponsesWithPolicyFallback(
109
+ req: Request,
110
+ config: OcxConfig,
111
+ logCtx: RequestLogContext,
112
+ options: CoreOptions = {},
113
+ deps: PolicyFallbackDeps = {},
114
+ ): Promise<Response> {
115
+ const runCore = deps.runCore ?? handleResponsesCore;
116
+ let rawBody: Record<string, unknown> | null = null;
117
+ try {
118
+ const parsed = await readJsonRequestBody(req.clone());
119
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) rawBody = parsed as Record<string, unknown>;
120
+ } catch {
121
+ // Core owns the client-facing parse/decompression error.
122
+ }
123
+
124
+ let response = await runCore(req, config, logCtx, options);
125
+ const initialTrace = logCtx.routeDecision;
126
+ const initialRequestedModel = logCtx.requestedModel;
127
+ if (!rawBody || !isPolicyDecision(initialTrace)) return response;
128
+
129
+ const tried = new Set<string>([
130
+ candidateKey({ provider: initialTrace.selected.provider, model: initialTrace.selected.model }),
131
+ ]);
132
+
133
+ while (await shouldHopPolicyCandidate(response, req.signal)) {
134
+ if (req.signal.aborted) return response;
135
+ const next = rankPolicyFallbackCandidates(initialTrace, tried)[0];
136
+ if (!next) return response;
137
+ tried.add(candidateKey(next));
138
+
139
+ finishFailedPolicyAttempt(logCtx, response.status);
140
+ const retryRequest = requestWithCandidate(req, rawBody, next);
141
+ try {
142
+ response = await runCore(retryRequest, config, logCtx, options);
143
+ } finally {
144
+ logCtx.requestedModel = initialRequestedModel;
145
+ logCtx.routeDecision = initialTrace;
146
+ }
147
+ }
148
+
149
+ return response;
150
+ }
151
+
152
+ export const handleResponses = handleResponsesWithPolicyFallback;
@@ -5,5 +5,6 @@ export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./respon
5
5
  export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload";
6
6
  export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse, handleResponsesCompact } from "./responses/compact";
7
7
  export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers";
8
- export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, handleResponses, linkAbortSignal } from "./responses/core";
9
- export { adapterNeedsForcedContinuation } from "./responses/core";
8
+ export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core";
9
+ export { handleResponses, handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "./responses/policy-fallback";
10
+ export { adapterNeedsForcedContinuation } from "./responses/core";
package/src/types.ts CHANGED
@@ -583,8 +583,8 @@ export interface OcxConfig {
583
583
  defaultProvider: string;
584
584
  /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */
585
585
  openaiProviderTierVersion?: 1 | 2;
586
- /** One-time migration marker for Antigravity's static catalog default. */
587
- googleAntigravityStaticCatalogVersion?: 1;
586
+ /** One-time migration marker for Antigravity's static-catalog defaults. */
587
+ googleAntigravityStaticCatalogVersion?: 1 | 2;
588
588
  /** Claude Code inbound + launcher settings. */
589
589
  claudeCode?: OcxClaudeCodeConfig;
590
590
  /**
@@ -700,6 +700,12 @@ export interface OcxConfig {
700
700
  disabledModels?: string[];
701
701
  /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */
702
702
  customModels?: OcxCustomModel[];
703
+ /**
704
+ * Internal, versioned evidence for reconciling custom-model deletions with
705
+ * pre-marker Codex catalog rows. Consumers must parse this defensively so a
706
+ * future state written by a newer binary survives older whole-config saves.
707
+ */
708
+ customModelCatalogMigration?: unknown;
703
709
  /**
704
710
  * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
705
711
  * commit messages, skill orchestration) to a user-chosen model. Default intercepted
@@ -899,6 +905,13 @@ export interface OcxComboConfig {
899
905
  * mandated model id; exact-match requests route here before any provider resolution.
900
906
  */
901
907
  alias?: string;
908
+ /**
909
+ * Explicitly allow a bare OpenAI-native alias (for example `gpt-5.6-sol`) to
910
+ * be represented by this routed combo. Never inferred from `alias`.
911
+ */
912
+ nativeAlias?: boolean;
913
+ /** Display-only label for the public catalog row. Required for native aliases. */
914
+ displayName?: string;
902
915
  }
903
916
 
904
917
  export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude";
package/src/usage/cost.ts CHANGED
Binary file
@@ -1,4 +1,5 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
+ import type { VisionReasoningEffort } from "../reasoning-effort";
2
3
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
4
  import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort";
4
5
  import { redactSecretString } from "../lib/redact";
@@ -9,6 +10,7 @@ import type { SidecarOutcomeRecorder } from "../web-search/executor";
9
10
 
10
11
  export interface VisionSettings {
11
12
  model: string;
13
+ reasoning: VisionReasoningEffort;
12
14
  timeoutMs: number;
13
15
  }
14
16
 
@@ -77,7 +79,7 @@ export async function describeImage(
77
79
  "verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on " +
78
80
  "what's relevant to the user's request. Output only the description.",
79
81
  input: [{ type: "message", role: "user", content }],
80
- reasoning: { effort: "low" },
82
+ reasoning: { effort: settings.reasoning },
81
83
  // The ChatGPT (codex) backend rejects `max_output_tokens` ("Unsupported parameter"); the
82
84
  // description is clamped downstream (DESC_MAX_CHARS) instead.
83
85
  store: false,