@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
@@ -13,6 +13,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
13
13
  import { loginCursor, refreshCursorToken } from "./cursor";
14
14
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
15
15
  import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
16
+ import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
16
17
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
17
18
  import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
18
19
  import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../providers/registry";
@@ -53,6 +54,8 @@ export interface OAuthAccessSnapshot {
53
54
  accountId: string;
54
55
  generation: string;
55
56
  accessToken: string;
57
+ /** Cloud Code Assist project selected during Antigravity login. */
58
+ projectId?: string;
56
59
  /** Safe request-routing subset; refresh-only Kiro client secrets never leave the credential store. */
57
60
  kiro?: Pick<KiroOAuthMetadata, "profileArn" | "apiRegion" | "ssoRegion">;
58
61
  }
@@ -204,7 +207,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
204
207
  defaultModel: oauthDefaultModel("google-antigravity"),
205
208
  },
206
209
  cursor: {
207
- login: (ctrl) => loginCursor(ctrl),
210
+ login: (ctrl, opts) => loginCursor(ctrl, undefined, { forceLogin: opts?.forceLogin }),
208
211
  refresh: refreshCursorToken,
209
212
  providerConfig: oauthConfig("cursor"),
210
213
  defaultModel: oauthDefaultModel("cursor"),
@@ -289,6 +292,7 @@ function accessSnapshot(provider: string, accountId: string, cred: OAuthCredenti
289
292
  accountId,
290
293
  generation: credentialGeneration(cred),
291
294
  accessToken: cred.access,
295
+ ...(cred.projectId ? { projectId: cred.projectId } : {}),
292
296
  // Stored account metadata remains authoritative. Metadata-less legacy/environment credentials
293
297
  // may use explicit environment routing, but never borrow the currently signed-in local CLI account.
294
298
  ...(provider === "kiro"
@@ -628,15 +632,15 @@ function modelDiscoveryTransportSeed(providerName: string, prov: OcxProviderConf
628
632
  }
629
633
 
630
634
  /**
631
- * Provider-correct `GET /models` request (URL + headers), so both model-listing paths fetch the
635
+ * Provider-correct model-discovery request (URL + headers), so both model-listing paths fetch the
632
636
  * LIVE catalog correctly per adapter. Anthropic is the special case: its endpoint is `/v1/models`
633
637
  * (not `/models`), it needs `anthropic-version`, and it authenticates with `x-api-key` by default
634
638
  * (or `Authorization: Bearer` when `apiKeyTransport = "bearer"`), plus the OAuth beta for oauth
635
639
  * mode — not a bare Bearer. Google (ai-studio mode)
636
640
  * is the other special case: `x-goog-api-key` + `/v1beta/models`, returning `{ models: [...] }`.
637
641
  * The catalog authority gate intentionally degrades that non-OpenAI shape to stale/static data.
638
- * Everyone else uses the OpenAI-style `/models` + Bearer with a `{ data: [{ id, owned_by? }] }`
639
- * response.
642
+ * Antigravity uses its CCA `:fetchAvailableModels` RPC; everyone else uses the OpenAI-style
643
+ * `/models` + Bearer with a `{ data: [{ id, owned_by? }] }` response.
640
644
  */
641
645
  export interface ModelsRequestObservedAuth {
642
646
  readonly oauthApiBaseUrl?: string;
@@ -647,7 +651,7 @@ export function buildModelsRequest(
647
651
  apiKey: string | undefined,
648
652
  providerName = "",
649
653
  observedAuth?: ModelsRequestObservedAuth,
650
- ): { url: string; headers: Record<string, string> } {
654
+ ): { method?: "POST"; url: string; headers: Record<string, string> } {
651
655
  const transportSeed = modelDiscoveryTransportSeed(providerName, prov);
652
656
  const copilotApiBaseUrl = observedAuth === undefined
653
657
  ? (providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined)
@@ -665,6 +669,17 @@ export function buildModelsRequest(
665
669
  effectiveProvider.baseUrl,
666
670
  defaultUrl,
667
671
  );
672
+ if (effectiveGoogleMode(providerName, effectiveProvider) === "cloud-code-assist") {
673
+ headers.Accept = "application/json";
674
+ headers["Content-Type"] = "application/json";
675
+ headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA;
676
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
677
+ return {
678
+ method: "POST",
679
+ url: discoveryUrl(`${effectiveProvider.baseUrl.replace(/\/+$/, "")}/v1internal:fetchAvailableModels`),
680
+ headers,
681
+ };
682
+ }
668
683
  if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
669
684
  // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
670
685
  // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
@@ -698,9 +713,7 @@ export function buildModelsRequest(
698
713
  *
699
714
  * Only touches providers that are registry-managed AND still `authMode: "oauth"`. Preset fields
700
715
  * are refreshed, while the registry's `liveModels` default is normally filled only when no value
701
- * is stored. Antigravity has one versioned exception below because its old GUI-generated `true`
702
- * cannot be distinguished from a hand-written pre-migration `true`. Persists + returns true when
703
- * anything changed.
716
+ * is stored. Persists + returns true when anything changed.
704
717
  */
705
718
  function cloneProviderField(value: unknown): unknown {
706
719
  if (Array.isArray(value)) return [...value];
@@ -729,7 +742,7 @@ const OAUTH_RECONCILE_FIELDS: (keyof OcxProviderConfig)[] = [
729
742
  ];
730
743
 
731
744
  const GOOGLE_ANTIGRAVITY_PROVIDER = "google-antigravity";
732
- const GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION = 1 as const;
745
+ const GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION = 2 as const;
733
746
 
734
747
  /** Only migrate the three-model experimental seed; an operator's later `liveModels: false` wins. */
735
748
  function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean {
@@ -738,10 +751,34 @@ function isLegacyCommandCodeStaticCatalog(provider: OcxProviderConfig): boolean
738
751
  && JSON.stringify(provider.models) === JSON.stringify(["deepseek-v4-flash", "kimi-k3", "glm-5.2"]);
739
752
  }
740
753
 
754
+ function isLegacyAntigravityStaticCatalog(provider: OcxProviderConfig): boolean {
755
+ return provider.liveModels === false
756
+ && provider.adapter === "google"
757
+ && provider.baseUrl === "https://daily-cloudcode-pa.googleapis.com"
758
+ && provider.authMode === "oauth"
759
+ && provider.googleMode === "cloud-code-assist"
760
+ && provider.defaultModel === "gemini-3.6-flash"
761
+ && JSON.stringify(provider.models) === JSON.stringify([
762
+ "gemini-3.6-flash",
763
+ "gemini-3.1-pro",
764
+ "gemini-3.1-flash-image",
765
+ "claude-sonnet-4-6",
766
+ "claude-opus-4-6-thinking",
767
+ "gpt-oss-120b-medium",
768
+ ]);
769
+ }
770
+
771
+ /** Promote only the versioned canonical static seed; unmarked `liveModels: false` remains user intent. */
772
+ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
773
+ if (config.googleAntigravityStaticCatalogVersion !== 1) return false;
774
+ const provider = config.providers[GOOGLE_ANTIGRAVITY_PROVIDER];
775
+ if (provider && isLegacyAntigravityStaticCatalog(provider)) provider.liveModels = true;
776
+ config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_LIVE_DISCOVERY_VERSION;
777
+ return true;
778
+ }
779
+
741
780
  export function reconcileOAuthProviders(config: OcxConfig): boolean {
742
- let changed = false;
743
- const migrateAntigravityStaticCatalog =
744
- config.googleAntigravityStaticCatalogVersion !== GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
781
+ let changed = migrateLegacyAntigravityStaticCatalog(config);
745
782
  for (const [name, prov] of Object.entries(config.providers)) {
746
783
  const def = OAUTH_PROVIDERS[name];
747
784
  if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) {
@@ -750,21 +787,7 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
750
787
  prov.liveModels = true;
751
788
  changed = true;
752
789
  }
753
- // Normalize the canonical row before the OAuth-only reconciliation guard. The old GUI and a
754
- // manual edit both persist the same bare `true`, with no source metadata, so every ambiguous
755
- // pre-marker value is reset once. A deliberate live-discovery choice can be re-enabled after
756
- // the marker and is then preserved. Do this before the guard so omitted/non-OAuth authMode
757
- // rows do not get stamped without actually receiving the new static default.
758
- if (name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog && prov.liveModels !== false) {
759
- prov.liveModels = false;
760
- changed = true;
761
- }
762
- // During the one-time Antigravity static-catalog migration, also refresh preset catalog
763
- // fields when authMode is omitted or non-oauth. Otherwise liveModels flips to static while
764
- // a stale models[] remains the published catalog forever.
765
- const migrateAntigravityCatalogFields =
766
- name === GOOGLE_ANTIGRAVITY_PROVIDER && migrateAntigravityStaticCatalog;
767
- if (!def || (prov.authMode !== "oauth" && !migrateAntigravityCatalogFields)) continue;
790
+ if (!def || prov.authMode !== "oauth") continue;
768
791
  const preset = def.providerConfig;
769
792
  for (const field of OAUTH_RECONCILE_FIELDS) {
770
793
  if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
@@ -775,9 +798,6 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
775
798
  }
776
799
  changed = true;
777
800
  }
778
- // Before this marker existed, the GUI materialized an omitted `liveModels` as `true` on any
779
- // settings save. Since persisted values have no provenance, the pre-guard normalization above
780
- // intentionally resets all pre-marker `true` values once. Later choices are version-bounded.
781
801
  if (prov.liveModels === undefined && preset.liveModels !== undefined) {
782
802
  prov.liveModels = preset.liveModels;
783
803
  changed = true;
@@ -791,10 +811,6 @@ export function reconcileOAuthProviders(config: OcxConfig): boolean {
791
811
  changed = true;
792
812
  }
793
813
  }
794
- if (migrateAntigravityStaticCatalog) {
795
- config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
796
- changed = true;
797
- }
798
814
  if (changed) saveConfig(config);
799
815
  return changed;
800
816
  }
@@ -851,17 +867,14 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
851
867
  if (provider === "chatgpt") return;
852
868
  const def = OAUTH_PROVIDERS[provider];
853
869
  if (!def) return;
870
+ if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) migrateLegacyAntigravityStaticCatalog(config);
854
871
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
855
872
  if (namespaceCollision) throw new Error(namespaceCollision);
856
873
  const existing = config.providers[provider];
857
874
  const next: OcxProviderConfig = { ...def.providerConfig };
858
- // `liveModels` is a user-facing provider toggle. A registry default seeds new rows, but an
859
- // explicit post-migration choice must survive re-login and the latest-config upsert. Old GUI
860
- // saves and manual edits left identical pre-marker `true` values, so that ambiguous state is
861
- // reset once; users who deliberately forced discovery can re-enable it after migration.
862
- const preserveExistingLiveModels = provider !== GOOGLE_ANTIGRAVITY_PROVIDER
863
- || config.googleAntigravityStaticCatalogVersion === GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
864
- if (preserveExistingLiveModels && typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) {
875
+ // `liveModels` is a user-facing provider toggle. Preserve either explicit setting across login;
876
+ // Antigravity's CCA discovery now uses its real RPC, so legacy `true` remains a valid choice.
877
+ if (typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) {
865
878
  next.liveModels = existing.liveModels;
866
879
  }
867
880
  // The Command Code protocol-version pin is an operator compatibility control. A re-login,
@@ -893,9 +906,6 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
893
906
  }
894
907
  }
895
908
  config.providers[provider] = next;
896
- if (provider === GOOGLE_ANTIGRAVITY_PROVIDER) {
897
- config.googleAntigravityStaticCatalogVersion = GOOGLE_ANTIGRAVITY_STATIC_CATALOG_VERSION;
898
- }
899
909
  }
900
910
 
901
911
  interface RunLoginDeps {
@@ -1023,11 +1033,15 @@ export async function runLogin(
1023
1033
  settleKiroTransaction(rawCred, true);
1024
1034
  if (provider !== "chatgpt") {
1025
1035
  try {
1036
+ const { clearModelCache } = await import("../codex/model-cache");
1037
+ const { clearGatherRoutedModelsInflight } = await import("../codex/catalog");
1038
+ clearModelCache(provider);
1039
+ clearGatherRoutedModelsInflight();
1026
1040
  const { clearAccountQuotaCache, clearProviderQuotaCache } = await import("../providers/quota");
1027
1041
  clearProviderQuotaCache();
1028
1042
  clearAccountQuotaCache(provider);
1029
1043
  } catch {
1030
- // Quota module may be unavailable in tightly scoped unit tests.
1044
+ // Optional state modules may be unavailable in tightly scoped unit tests.
1031
1045
  }
1032
1046
  }
1033
1047
  return cred;
@@ -1,10 +1,13 @@
1
+ import { isValidModelDiscoveryModelId, MODEL_DISCOVERY_MAX_MODELS } from "./model-discovery-limits";
2
+
1
3
  // Google Antigravity (Cloud Code Assist) bundled model list.
2
4
  //
3
5
  // Single source of truth: the Antigravity `:fetchAvailableModels` backend, the same one the `agy`
4
6
  // CLI resolves labels against. The ids below separate CCA wire ids, collapsed picker entries,
5
7
  // and hidden compatibility aliases for saved selections. The CCA envelope's `model` field must
6
8
  // receive the wire id (for example "Gemini 3.1 Pro (High)" => gemini-pro-agent), while the
7
- // picker exposes collapsed base models with reasoning-effort routing.
9
+ // picker exposes collapsed base models only when CCA returns every known tier; otherwise each
10
+ // returned wire id remains visible so an unavailable tier cannot be selected.
8
11
 
9
12
  // ── Wire IDs (what CCA :fetchAvailableModels returns) ──
10
13
  const ANTIGRAVITY_WIRE_MODELS = [
@@ -19,6 +22,21 @@ const ANTIGRAVITY_WIRE_MODELS = [
19
22
  "gpt-oss-120b-medium",
20
23
  ];
21
24
 
25
+ const ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID: Record<string, string> = {
26
+ "gemini-3.6-flash-low": "gemini-3.6-flash",
27
+ "gemini-3.6-flash-medium": "gemini-3.6-flash",
28
+ "gemini-3.6-flash-high": "gemini-3.6-flash",
29
+ "gemini-3.1-pro-low": "gemini-3.1-pro",
30
+ "gemini-pro-agent": "gemini-3.1-pro",
31
+ };
32
+
33
+ const ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL: Record<string, string[]> = Object.entries(
34
+ ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID,
35
+ ).reduce<Record<string, string[]>>((out, [wireId, pickerId]) => {
36
+ (out[pickerId] ??= []).push(wireId);
37
+ return out;
38
+ }, {});
39
+
22
40
  // ── Effort ladders per collapsed base model ──
23
41
  // Gemini models: effort → wire model suffix (official agy UI pattern).
24
42
  // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern).
@@ -100,8 +118,8 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
100
118
  "gemini-3.1-pro-low": 1_048_576,
101
119
  "gemini-pro-agent": 1_048_576,
102
120
  "gemini-3.1-flash-image": 1_048_576,
103
- "claude-sonnet-4-6": 200_000,
104
- "claude-opus-4-6-thinking": 1_000_000,
121
+ "claude-sonnet-4-6": 250_000,
122
+ "claude-opus-4-6-thinking": 250_000,
105
123
  "gpt-oss-120b-medium": 131_072,
106
124
  };
107
125
 
@@ -119,6 +137,100 @@ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
119
137
  ),
120
138
  };
121
139
 
140
+ export const ANTIGRAVITY_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
141
+ "gemini-3.6-flash": ["text", "image"],
142
+ "gemini-3.1-pro": ["text", "image"],
143
+ "gemini-3.1-flash-image": ["text", "image"],
144
+ "claude-sonnet-4-6": ["text", "image"],
145
+ "claude-opus-4-6-thinking": ["text", "image"],
146
+ "gpt-oss-120b-medium": ["text"],
147
+ };
148
+
149
+ export interface AntigravityAvailableModel {
150
+ id: string;
151
+ contextWindow?: number;
152
+ inputModalities?: string[];
153
+ }
154
+
155
+ function antigravityRecord(value: unknown): Record<string, unknown> | undefined {
156
+ return value !== null && typeof value === "object" && !Array.isArray(value)
157
+ ? value as Record<string, unknown>
158
+ : undefined;
159
+ }
160
+
161
+ function antigravityPositiveInteger(value: unknown): number | undefined {
162
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
163
+ }
164
+
165
+ /**
166
+ * Extract the CCA models that are valid for agent requests. The endpoint also returns tab,
167
+ * command, commit-message, transcription, and standalone image-generation models; those are not
168
+ * callable through the CCA agent envelope and must not be published to the Codex catalog.
169
+ */
170
+ export function parseAntigravityAvailableModels(
171
+ payload: unknown,
172
+ maxModels = MODEL_DISCOVERY_MAX_MODELS,
173
+ ): AntigravityAvailableModel[] | null {
174
+ const limit = Number.isSafeInteger(maxModels) && maxModels > 0
175
+ ? Math.min(maxModels, MODEL_DISCOVERY_MAX_MODELS)
176
+ : MODEL_DISCOVERY_MAX_MODELS;
177
+ const body = antigravityRecord(payload);
178
+ if (!body) return null;
179
+ const models = antigravityRecord(body?.models);
180
+ const sorts = Array.isArray(body?.agentModelSorts) ? body.agentModelSorts : undefined;
181
+ if (!models || !sorts) return null;
182
+
183
+ const ids: string[] = [];
184
+ for (const sort of sorts) {
185
+ const groups = antigravityRecord(sort)?.groups;
186
+ if (!Array.isArray(groups)) return null;
187
+ for (const group of groups) {
188
+ const modelIds = antigravityRecord(group)?.modelIds;
189
+ if (!Array.isArray(modelIds)) return null;
190
+ for (const id of modelIds) {
191
+ if (!isValidModelDiscoveryModelId(id)
192
+ || !antigravityRecord(models[id])
193
+ || ids.length >= limit) return null;
194
+ ids.push(id);
195
+ }
196
+ }
197
+ }
198
+ // This model is exposed by Antigravity's agent chat surface even though it is grouped under
199
+ // image generation in the discovery response.
200
+ if (Array.isArray(body.imageGenerationModelIds)
201
+ && body.imageGenerationModelIds.includes("gemini-3.1-flash-image")) {
202
+ if (ids.length >= limit) return null;
203
+ ids.push("gemini-3.1-flash-image");
204
+ }
205
+
206
+ const available = new Map<string, Record<string, unknown>>();
207
+ for (const wireId of ids) {
208
+ const info = antigravityRecord(models[wireId]);
209
+ if (!info || available.has(wireId)) continue;
210
+ // Legacy compatibility aliases are deliberately routed to newer wire ids for saved
211
+ // selections. They are not safe as independently discovered picker rows.
212
+ if (ANTIGRAVITY_MODEL_ALIASES[wireId] && ANTIGRAVITY_MODEL_ALIASES[wireId] !== wireId) continue;
213
+ available.set(wireId, info);
214
+ }
215
+
216
+ const out: AntigravityAvailableModel[] = [];
217
+ const seen = new Set<string>();
218
+ for (const [wireId, info] of available) {
219
+ const pickerId = ANTIGRAVITY_PICKER_MODEL_BY_WIRE_ID[wireId];
220
+ const completePickerSet = pickerId !== undefined
221
+ && ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL[pickerId]!.every(id => available.has(id));
222
+ const id = completePickerSet ? pickerId! : wireId;
223
+ if (seen.has(id)) continue;
224
+ seen.add(id);
225
+ out.push({
226
+ id,
227
+ ...(antigravityPositiveInteger(info.maxTokens) ? { contextWindow: antigravityPositiveInteger(info.maxTokens) } : {}),
228
+ inputModalities: info.supportsImages === true ? ["text", "image"] : ["text"],
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+
122
234
  export function resolveAntigravityWireModelId(modelId: string): string {
123
235
  return ANTIGRAVITY_MODEL_ALIASES[modelId] ?? modelId;
124
236
  }
@@ -33,19 +33,27 @@ export function globalContextCapValue(config: Pick<OcxConfig, "contextCapValue">
33
33
  return isValidContextCap(value) ? Math.floor(value) : DEFAULT_PROVIDER_CONTEXT_CAP;
34
34
  }
35
35
 
36
- export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean): void {
36
+ export function setProviderContextCap(config: OcxConfig, provider: string, enabled: boolean, value?: number): void {
37
37
  const next = providerContextCaps(config);
38
- if (enabled) next[provider] = globalContextCapValue(config);
39
- else delete next[provider];
38
+ if (enabled) {
39
+ next[provider] = isValidContextCap(value) ? Math.floor(value) : globalContextCapValue(config);
40
+ } else {
41
+ delete next[provider];
42
+ }
40
43
  if (Object.keys(next).length > 0) config.providerContextCaps = next;
41
44
  else delete config.providerContextCaps;
42
45
  }
43
46
 
44
- /** Set the global cap value and re-point every already-enabled provider to it. */
45
- export function setGlobalContextCapValue(config: OcxConfig, value: number): void {
47
+ /**
48
+ * Set the global cap value (the default used by per-provider toggles and "set all").
49
+ * Re-points every already-enabled provider only when `applyToAll` is true (the dashboard's
50
+ * "apply to every routed provider" toggle); otherwise each provider keeps its own cap value.
51
+ */
52
+ export function setGlobalContextCapValue(config: OcxConfig, value: number, applyToAll: boolean): void {
46
53
  if (!isValidContextCap(value)) return;
47
54
  const next = Math.floor(value);
48
55
  config.contextCapValue = next;
56
+ if (!applyToAll) return;
49
57
  const caps = providerContextCaps(config);
50
58
  for (const provider of Object.keys(caps)) caps[provider] = next;
51
59
  if (Object.keys(caps).length > 0) config.providerContextCaps = caps;
@@ -0,0 +1,16 @@
1
+ /** Hard process-wide limits shared by all live model-discovery parsers. */
2
+ export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
3
+ export const MODEL_DISCOVERY_MAX_MODELS = 2_000;
4
+ export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024;
5
+
6
+ const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
7
+
8
+ /** Reject model IDs that cannot safely be published as callable catalog selectors. */
9
+ export function isValidModelDiscoveryModelId(value: unknown): value is string {
10
+ if (typeof value !== "string") return false;
11
+ const normalized = value.trim();
12
+ return Boolean(normalized)
13
+ && normalized === value
14
+ && normalized.length <= MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH
15
+ && !MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalized);
16
+ }
@@ -1,4 +1,16 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
+ import {
3
+ isValidModelDiscoveryModelId,
4
+ MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH,
5
+ MODEL_DISCOVERY_MAX_MODELS,
6
+ MODEL_DISCOVERY_MAX_RESPONSE_BYTES,
7
+ } from "./model-discovery-limits";
8
+ export {
9
+ isValidModelDiscoveryModelId,
10
+ MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH,
11
+ MODEL_DISCOVERY_MAX_MODELS,
12
+ MODEL_DISCOVERY_MAX_RESPONSE_BYTES,
13
+ } from "./model-discovery-limits";
2
14
  import {
3
15
  getProviderRegistryEntry,
4
16
  providerMatchesRegistryTransport,
@@ -9,13 +21,8 @@ import {
9
21
  type ProviderModelDiscoverySpec,
10
22
  } from "./registry";
11
23
 
12
- /** Hard process-wide limits. Registry entries may lower, but never raise, these ceilings. */
13
- export const MODEL_DISCOVERY_MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
14
- export const MODEL_DISCOVERY_MAX_MODELS = 2_000;
15
- export const MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH = 1_024;
16
24
  const MODEL_DISCOVERY_MAX_FILTER_VALUES = 256;
17
25
  const MODEL_DISCOVERY_MAX_FILTER_STRING_LENGTH = 1_024;
18
- const MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
19
26
 
20
27
  export interface ResolvedProviderModelDiscovery {
21
28
  spec?: ProviderModelDiscoverySpec;
@@ -343,16 +350,8 @@ export function extractProviderModelItems(
343
350
  return { ok: false, reason: "invalid_shape" };
344
351
  }
345
352
  const id = (raw as { id?: unknown }).id;
346
- if (typeof id !== "string") return { ok: false, reason: "invalid_shape" };
347
- const normalizedId = id.trim();
348
- if (
349
- !normalizedId
350
- || normalizedId !== id
351
- || normalizedId.length > MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH
352
- || MODEL_DISCOVERY_MODEL_ID_CONTROL_CHARS.test(normalizedId)
353
- ) {
354
- return { ok: false, reason: "invalid_shape" };
355
- }
353
+ if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
354
+ const normalizedId = id;
356
355
  const item = raw as ProviderModelsApiItem;
357
356
  if (!providerModelMatchesDiscoveryFilter(item, discovery.spec?.filter) || seen.has(normalizedId)) continue;
358
357
  seen.add(normalizedId);
@@ -148,3 +148,32 @@ export function rewriteProviderReferences(config: OcxConfig, from: string, to: s
148
148
 
149
149
  return { changed, collisions };
150
150
  }
151
+
152
+ /**
153
+ * Drop the custom-model rows that belonged to a provider being removed.
154
+ *
155
+ * The sibling of the rename pass above. `rewriteProviderReferences` already
156
+ * carries `customModels[].provider` across a rename, so the array tracks the
157
+ * provider lifecycle — but removal used to delete only `config.providers[name]`
158
+ * and leave the rows behind. Those orphans still reach `/api/models` and the
159
+ * generated Codex catalog, which key on the row rather than on provider
160
+ * existence, so they surface as models that resolve to nothing (#1273).
161
+ *
162
+ * Only the rows are touched: the `customModelCatalogMigration` marker records
163
+ * one-time ownership of pre-marker rows and must survive removal unchanged, or
164
+ * an older binary's view of that ownership silently changes.
165
+ *
166
+ * Returns the number of rows dropped so callers can report it.
167
+ */
168
+ export function dropProviderCustomModels(config: OcxConfig, provider: string): number {
169
+ const existing = config.customModels;
170
+ if (!Array.isArray(existing) || existing.length === 0) return 0;
171
+ const kept = existing.filter(model => model.provider !== provider);
172
+ if (kept.length === existing.length) return 0;
173
+ // Match the add/remove routes: an emptied list is dropped rather than left as
174
+ // `[]`, so the `customModels` field is absent either way. Only that field —
175
+ // the `customModelCatalogMigration` marker is deliberately left in place.
176
+ if (kept.length > 0) config.customModels = kept;
177
+ else delete config.customModels;
178
+ return existing.length - kept.length;
179
+ }
@@ -11,6 +11,7 @@ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
11
11
  import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
12
12
  import { antigravityUserAgent } from "../adapters/client-fingerprint";
13
13
  import { apiKeyPoolEntryId } from "./api-keys";
14
+ import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
14
15
  import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry";
15
16
  import type { OcxConfig, OcxProviderConfig } from "../types";
16
17
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./openai-tiers";
@@ -44,6 +45,8 @@ const VENICE_BASE_URL = "https://api.venice.ai/api/v1";
44
45
  const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2";
45
46
  const DEEPINFRA_BASE_URL = "https://api.deepinfra.com";
46
47
  const NEURALWATT_BASE_URL = "https://api.neuralwatt.com/v1";
48
+ const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
49
+ const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`;
47
50
  /** Keep a failed probe's previous row at most this long before dropping it. */
48
51
  const LAST_GOOD_MAX_AGE_MS = CODEX_CAPACITY_MAX_QUOTA_AGE_MS;
49
52
  const nativeMainReportGenerations = new WeakMap<ProviderQuotaReport, number>();
@@ -977,6 +980,65 @@ function centsValue(value: unknown): number | undefined {
977
980
  return rec ? toFiniteNumber(rec.val) : undefined;
978
981
  }
979
982
 
983
+ /** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */
984
+ function xaiUserIdFromAccessToken(accessToken: string): string | undefined {
985
+ const parts = accessToken.split(".");
986
+ if (parts.length < 2 || !parts[1]) return undefined;
987
+ try {
988
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown };
989
+ return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined;
990
+ } catch {
991
+ return undefined;
992
+ }
993
+ }
994
+
995
+ /**
996
+ * Grok Build weekly credits envelope:
997
+ * `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`.
998
+ * Omitted percent is treated as 0 (proto3 default).
999
+ */
1000
+ export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null {
1001
+ const body = asRecord(value);
1002
+ const config = asRecord(body?.config);
1003
+ if (!config) return null;
1004
+ const period = asRecord(config.currentPeriod);
1005
+ if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null;
1006
+ const resetAt = normalizeResetAt(period.end);
1007
+ if (resetAt === undefined) return null;
1008
+ if (config.creditUsagePercent !== undefined) {
1009
+ const percent = normalizePercent(config.creditUsagePercent);
1010
+ if (percent === undefined) return null;
1011
+ return { percent, resetAt };
1012
+ }
1013
+ return { percent: 0, resetAt };
1014
+ }
1015
+
1016
+ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise<ProviderQuota | null> {
1017
+ try {
1018
+ const response = await fetch(XAI_CREDITS_URL, {
1019
+ headers: {
1020
+ Accept: "application/json",
1021
+ Authorization: `Bearer ${accessToken}`,
1022
+ [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli",
1023
+ [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response",
1024
+ "x-userid": userId,
1025
+ [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION,
1026
+ },
1027
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1028
+ });
1029
+ if (!response.ok) return null;
1030
+ const parsed = parseXaiCreditsResponse(await response.json().catch(() => null));
1031
+ if (!parsed) return null;
1032
+ return {
1033
+ weeklyPercent: parsed.percent,
1034
+ ...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}),
1035
+ updatedAt: Date.now(),
1036
+ };
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+
980
1042
  async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | null> {
981
1043
  let accessToken: string;
982
1044
  try {
@@ -984,25 +1046,37 @@ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | nu
984
1046
  } catch {
985
1047
  return null;
986
1048
  }
987
- const response = await fetch("https://cli-chat-proxy.grok.com/v1/billing", {
988
- headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
989
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
990
- });
991
- if (!response.ok) return null;
992
- const body = asRecord(await response.json().catch(() => null));
993
- const config = asRecord(body?.config);
994
- if (!config) return null;
995
- const limitCents = centsValue(config.monthlyLimit);
996
- const usedCents = centsValue(config.used);
997
- if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null;
998
- const percent = normalizePercent((usedCents / limitCents) * 100);
999
- if (percent === undefined) return null;
1000
- const quota: ProviderQuota = {
1001
- monthlyPercent: percent,
1002
- monthlyResetAt: normalizeResetAt(config.billingPeriodEnd),
1003
- updatedAt: Date.now(),
1004
- };
1005
- return report(provider, "xai:grok-billing", quota);
1049
+
1050
+ // Prefer the SuperGrok weekly credits window that actually gates prompting (#1283).
1051
+ const userId = getCredential("xai")?.accountId?.trim() || xaiUserIdFromAccessToken(accessToken);
1052
+ if (userId) {
1053
+ const weekly = await fetchXaiWeeklyCredits(accessToken, userId);
1054
+ if (weekly) return report(provider, "xai:grok-billing-credits", weekly);
1055
+ }
1056
+
1057
+ // Legacy monthly dollar pool — retained when weekly is unavailable.
1058
+ try {
1059
+ const response = await fetch(XAI_BILLING_URL, {
1060
+ headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` },
1061
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1062
+ });
1063
+ if (!response.ok) return null;
1064
+ const body = asRecord(await response.json().catch(() => null));
1065
+ const config = asRecord(body?.config);
1066
+ if (!config) return null;
1067
+ const limitCents = centsValue(config.monthlyLimit);
1068
+ const usedCents = centsValue(config.used);
1069
+ if (limitCents === undefined || usedCents === undefined || limitCents <= 0) return null;
1070
+ const percent = normalizePercent((usedCents / limitCents) * 100);
1071
+ if (percent === undefined) return null;
1072
+ return report(provider, "xai:grok-billing", {
1073
+ monthlyPercent: percent,
1074
+ monthlyResetAt: normalizeResetAt(config.billingPeriodEnd),
1075
+ updatedAt: Date.now(),
1076
+ });
1077
+ } catch {
1078
+ return null;
1079
+ }
1006
1080
  }
1007
1081
 
1008
1082
  function parseClaudeBucket(value: unknown): { percent?: number; resetAt?: number } | null {