@bitkyc08/opencodex 2.14.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/gui/dist/assets/index-B5T5ADgY.js +76 -0
  2. package/gui/dist/assets/{index-DWhX3yMp.css → index-DUCH59lJ.css} +1 -1
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/command-code.ts +15 -4
  6. package/src/adapters/cursor/effort-map.ts +4 -5
  7. package/src/adapters/cursor/request-builder.ts +55 -11
  8. package/src/adapters/cursor/tool-definitions.ts +24 -0
  9. package/src/adapters/kiro.ts +10 -1
  10. package/src/adapters/openai-chat.ts +5 -3
  11. package/src/adapters/openai-responses.ts +109 -0
  12. package/src/adapters/tool-catalog-nudge.ts +26 -4
  13. package/src/bridge.ts +50 -3
  14. package/src/cli/init.ts +4 -17
  15. package/src/codex/catalog/effort.ts +2 -1
  16. package/src/codex/catalog/metadata.ts +62 -12
  17. package/src/codex/catalog/native-models.ts +27 -0
  18. package/src/codex/catalog/parsing.ts +17 -2
  19. package/src/codex/catalog/provider-fetch.ts +47 -5
  20. package/src/codex/catalog/sync.ts +21 -7
  21. package/src/codex/catalog.ts +1 -1
  22. package/src/config.ts +79 -4
  23. package/src/generated/compatibility-version.json +54 -42
  24. package/src/generated/model-metadata.ts +1 -1
  25. package/src/lib/app-owned-memory-stores.ts +22 -0
  26. package/src/lib/tool-argument-integers.ts +158 -0
  27. package/src/oauth/index.ts +3 -0
  28. package/src/oauth/nous.ts +58 -9
  29. package/src/providers/antigravity-models.ts +93 -28
  30. package/src/providers/base-url-choices.ts +10 -0
  31. package/src/providers/command-code-efforts.ts +18 -0
  32. package/src/providers/model-rename-migration.ts +255 -0
  33. package/src/providers/model-rename-startup.ts +28 -0
  34. package/src/providers/openai-tier-startup.ts +31 -2
  35. package/src/providers/quota.ts +9 -2
  36. package/src/providers/registry.ts +13 -6
  37. package/src/responses/spill-store.ts +5 -1
  38. package/src/responses/state.ts +50 -2
  39. package/src/server/index.ts +2 -1
  40. package/src/server/management/api-key-usage.ts +31 -5
  41. package/src/server/management/logs-usage-routes.ts +48 -10
  42. package/src/server/management/provider-routes.ts +2 -1
  43. package/src/server/management/usage-summary-cache.ts +7 -1
  44. package/src/server/responses/collaboration.ts +12 -2
  45. package/src/server/responses/core.ts +33 -16
  46. package/src/server/startup-health-cache.ts +12 -0
  47. package/src/usage/expected-prices.ts +13 -0
  48. package/src/usage/log.ts +430 -12
  49. package/gui/dist/assets/index-DuaUVm_d.js +0 -76
package/src/oauth/nous.ts CHANGED
@@ -39,6 +39,7 @@ import { join } from "node:path";
39
39
  import type { OAuthController, OAuthCredentials } from "./types";
40
40
  import { getAuthStorePath } from "./store";
41
41
  import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
42
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
42
43
 
43
44
  export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
44
45
  export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1";
@@ -87,6 +88,44 @@ interface NousJwtPayload {
87
88
  [key: string]: unknown;
88
89
  }
89
90
 
91
+ async function readOAuthBytes(response: Response, signal: AbortSignal): Promise<Uint8Array> {
92
+ const { bytes, oversized } = await readBoundedResponseBytes(response, {
93
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
94
+ signal,
95
+ });
96
+ if (oversized) {
97
+ throw new NousTokenError(
98
+ response.status,
99
+ "response_too_large",
100
+ `Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
101
+ );
102
+ }
103
+ return bytes;
104
+ }
105
+
106
+ function parseOAuthJson(bytes: Uint8Array): unknown {
107
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
108
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
109
+ ? parsed as Record<string, unknown>
110
+ : {};
111
+ }
112
+
113
+ async function readOAuthJson(response: Response, signal: AbortSignal): Promise<unknown> {
114
+ return parseOAuthJson(await readOAuthBytes(response, signal));
115
+ }
116
+
117
+ async function readOAuthJsonOrEmpty(response: Response, signal: AbortSignal): Promise<unknown> {
118
+ const bytes = await readOAuthBytes(response, signal);
119
+ try {
120
+ return parseOAuthJson(bytes);
121
+ } catch {
122
+ // Preserve the pre-PR behavior for empty/HTML/malformed JSON only. Body
123
+ // read failures, timeouts, caller cancellation, and size-limit errors have
124
+ // already escaped readOAuthBytes and must retain their real identity.
125
+ return {};
126
+ }
127
+ }
128
+
90
129
  // ── Durable refresh-intent (review blocker #2) ──────────────────────────────
91
130
  // A refresh-intent file records that we submitted `refreshToken` to the Portal
92
131
  // and whether we are certain the rotated token was persisted. It lives next to
@@ -495,6 +534,7 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
495
534
  expiresInMs: number;
496
535
  intervalMs: number;
497
536
  }> {
537
+ const effectiveSignal = requestSignal(signal);
498
538
  const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
499
539
  method: "POST",
500
540
  headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
@@ -503,14 +543,16 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
503
543
  scope: NOUS_OAUTH_SCOPE,
504
544
  }),
505
545
  redirect: "error",
506
- signal: requestSignal(signal),
546
+ signal: effectiveSignal,
507
547
  });
508
- if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({})));
548
+ if (!response.ok) {
549
+ throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response, effectiveSignal));
550
+ }
509
551
  // A successful HTTP response may still carry an empty/HTML/non-JSON body.
510
552
  // Fall back to an empty object so the required-field check below produces the
511
553
  // clear "missing required fields" validation error instead of leaking a raw
512
554
  // JSON parser exception.
513
- const payload = (await response.json().catch(() => ({}))) as NousDeviceAuthorizationResponse;
555
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousDeviceAuthorizationResponse;
514
556
  const userCode = nonEmptyString(payload.user_code);
515
557
  const deviceCode = nonEmptyString(payload.device_code);
516
558
  const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri);
@@ -549,6 +591,7 @@ async function pollForToken(
549
591
  while (Date.now() < deadline) {
550
592
  if (signal?.aborted) throw new Error("Login cancelled");
551
593
  let response: Response;
594
+ const effectiveSignal = requestSignal(signal);
552
595
  try {
553
596
  response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
554
597
  method: "POST",
@@ -559,7 +602,7 @@ async function pollForToken(
559
602
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
560
603
  }),
561
604
  redirect: "error",
562
- signal: requestSignal(signal),
605
+ signal: effectiveSignal,
563
606
  });
564
607
  } catch (netErr) {
565
608
  // Genuine cancellation must abort immediately. Any other transport-level
@@ -571,13 +614,15 @@ async function pollForToken(
571
614
  if (await sleepUntilDeadline(waitMs)) continue;
572
615
  break;
573
616
  }
617
+ // Parse under the same deadline that covered the request headers. Keep the
618
+ // read outside the fetch retry catch: a bounded-reader error or caller
619
+ // cancellation is an observed response failure, not a safe poll retry.
620
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousTokenResponse;
574
621
  // Parse once and pass the payload through to the failure path (review #8),
575
622
  // so we never try to re-read a body that has already been consumed.
576
623
  // Normalize a successful-but-non-object body (for example valid JSON
577
624
  // `null`) to an empty object so the required-field validation below
578
625
  // produces a terminal NousTokenError instead of a raw TypeError.
579
- const parsed = (await response.json().catch(() => ({}))) as unknown;
580
- const payload = (parsed && typeof parsed === "object" ? parsed : {}) as NousTokenResponse;
581
626
  if (Date.now() >= deadline) break;
582
627
  if (response.ok) return parseTokenPayload(payload, "");
583
628
  const error = payload.error;
@@ -670,6 +715,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
670
715
  }
671
716
 
672
717
  let response: Response;
718
+ const effectiveSignal = requestSignal(signal);
673
719
  try {
674
720
  response = await fetch(`${baseUrl}/api/oauth/token`, {
675
721
  method: "POST",
@@ -683,7 +729,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
683
729
  client_id: NOUS_OAUTH_CLIENT_ID,
684
730
  }),
685
731
  redirect: "error",
686
- signal: requestSignal(signal),
732
+ signal: effectiveSignal,
687
733
  });
688
734
  } catch (netErr) {
689
735
  // The request may have reached the server and rotated the token even on a
@@ -703,7 +749,6 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
703
749
 
704
750
  if (!response.ok) {
705
751
  const status = response.status;
706
- const payload = await response.json().catch(() => ({}));
707
752
  // The request reached the Portal's token endpoint. A non-2xx response does
708
753
  // NOT establish that the single-use refresh token was not consumed: 429
709
754
  // rate limits, unknown/custom 4xx, and gateway-generated client-class
@@ -719,6 +764,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
719
764
  // The pre-dispatch "submitted" intent is still on disk, which also
720
765
  // blocks replay; surface the original HTTP error below.
721
766
  }
767
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal);
722
768
  throw tokenErrorFromPayload(status, payload);
723
769
  }
724
770
 
@@ -727,7 +773,10 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
727
773
  // replay it. On success we deliberately LEAVE the intent as "submitted"
728
774
  // (the store clears it once the rotated token is persisted).
729
775
  try {
730
- const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken);
776
+ const creds = parseTokenPayload(
777
+ (await readOAuthJson(response, effectiveSignal)) as NousTokenResponse,
778
+ refreshToken,
779
+ );
731
780
  return creds;
732
781
  } catch (e) {
733
782
  try {
@@ -10,10 +10,39 @@ import { isValidModelDiscoveryModelId, MODEL_DISCOVERY_MAX_MODELS } from "./mode
10
10
  // returned wire id remains visible so an unavailable tier cannot be selected.
11
11
 
12
12
  // ── Wire IDs (what CCA :fetchAvailableModels returns) ──
13
+
14
+ /** Current Antigravity Flash generation. */
15
+ const GEMINI_FLASH_CURRENT = "gemini-3.7-flash";
16
+
17
+ /**
18
+ * Retired Flash ids → the reasoning tier they used to encode.
19
+ *
20
+ * Google pulls the previous Flash model from CCA almost immediately when the next
21
+ * one ships, so a saved selection cannot keep pointing at it. A flat alias would
22
+ * strand the user's tier: `resolveAntigravityEffortWireModel` rule 1 treats any
23
+ * alias as "the suffix IS the effort" and deliberately sends no thinkingConfig, so
24
+ * `gemini-3.6-flash-high` would silently become an untiered 3.7 call. Carrying the
25
+ * level here preserves what the user actually chose.
26
+ *
27
+ * 3.7 exposes tiers through `thinkingLevel` on ONE wire id rather than through
28
+ * suffixed wire ids, which is why the mapping is id → level and not id → id.
29
+ */
30
+ const RETIRED_FLASH_TIERS: Record<string, string> = {
31
+ // 3.6 generation.
32
+ "gemini-3.6-flash": "medium", // bare base carried a medium default
33
+ "gemini-3.6-flash-low": "low",
34
+ "gemini-3.6-flash-medium": "medium",
35
+ "gemini-3.6-flash-high": "high",
36
+ // 3.5 generation — these already pointed at 3.6 wire ids, which are now dead too.
37
+ "gemini-3.5-flash-extra-low": "low",
38
+ "gemini-3.5-flash-low": "medium",
39
+ "gemini-3.5-flash-mid": "medium",
40
+ "gemini-3.5-flash-high": "high",
41
+ "gemini-3-flash-agent": "high",
42
+ };
43
+
13
44
  const ANTIGRAVITY_WIRE_MODELS = [
14
- "gemini-3.6-flash-low",
15
- "gemini-3.6-flash-medium",
16
- "gemini-3.6-flash-high",
45
+ "gemini-3.7-flash",
17
46
  "gemini-3.1-pro-low",
18
47
  "gemini-pro-agent",
19
48
  "gemini-3.1-flash-image",
@@ -23,9 +52,6 @@ const ANTIGRAVITY_WIRE_MODELS = [
23
52
  ];
24
53
 
25
54
  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
55
  "gemini-3.1-pro-low": "gemini-3.1-pro",
30
56
  "gemini-pro-agent": "gemini-3.1-pro",
31
57
  };
@@ -41,7 +67,7 @@ const ANTIGRAVITY_WIRE_IDS_BY_PICKER_MODEL: Record<string, string[]> = Object.en
41
67
  // Gemini models: effort → wire model suffix (official agy UI pattern).
42
68
  // Claude Opus: effort → thinkingConfig.thinkingLevel (CLIProxyAPI proven pattern).
43
69
  export const ANTIGRAVITY_MODEL_EFFORTS: Record<string, string[]> = {
44
- "gemini-3.6-flash": ["low", "medium", "high"],
70
+ "gemini-3.7-flash": ["low", "medium", "high"],
45
71
  "gemini-3.1-pro": ["low", "high"],
46
72
  "claude-sonnet-4-6": ["low", "medium", "high", "max"],
47
73
  "claude-opus-4-6-thinking": ["low", "medium", "high", "max"],
@@ -49,11 +75,6 @@ export const ANTIGRAVITY_MODEL_EFFORTS: Record<string, string[]> = {
49
75
 
50
76
  // ── Effort → wire model map for Gemini base models ──
51
77
  const ANTIGRAVITY_EFFORT_WIRE_MAP: Record<string, Record<string, string>> = {
52
- "gemini-3.6-flash": {
53
- low: "gemini-3.6-flash-low",
54
- medium: "gemini-3.6-flash-medium",
55
- high: "gemini-3.6-flash-high",
56
- },
57
78
  "gemini-3.1-pro": {
58
79
  low: "gemini-3.1-pro-low",
59
80
  high: "gemini-pro-agent",
@@ -62,11 +83,20 @@ const ANTIGRAVITY_EFFORT_WIRE_MAP: Record<string, Record<string, string>> = {
62
83
 
63
84
  // ── Default effort per Gemini base model ──
64
85
  const ANTIGRAVITY_DEFAULT_EFFORT: Record<string, string> = {
65
- "gemini-3.6-flash": "medium",
66
86
  "gemini-3.1-pro": "high",
67
87
  };
68
88
 
69
- const ANTIGRAVITY_THINKING_LEVELS = new Set(["minimal", "low", "medium", "high"]);
89
+ /**
90
+ * Gemini base models whose efforts ride on `thinkingLevel` against a single wire id
91
+ * instead of suffixed wire ids, with the level applied when the caller names none.
92
+ */
93
+ const ANTIGRAVITY_THINKING_LEVEL_MODELS: Record<string, string> = {
94
+ "gemini-3.7-flash": "medium",
95
+ };
96
+
97
+ // `minimal` is deliberately absent: Google documents it as unsupported for the current
98
+ // Flash generation, where it is an error rather than a quieter tier.
99
+ const ANTIGRAVITY_THINKING_LEVELS = new Set(["low", "medium", "high"]);
70
100
 
71
101
  function resolveAntigravityThinkingLevel(effort: string): string | undefined {
72
102
  if (effort === "xhigh" || effort === "max" || effort === "ultra") return "high";
@@ -83,16 +113,17 @@ const ANTIGRAVITY_VISIBLE_MODEL_ALIASES: Record<string, string> = {
83
113
  // Wire suffix IDs are identity aliases — they resolve to themselves so saved configs
84
114
  // with explicit suffixes (e.g. gemini-3.6-flash-low) continue to work.
85
115
  const ANTIGRAVITY_COMPATIBILITY_MODEL_ALIASES: Record<string, string> = {
86
- "gemini-3.6-flash-low": "gemini-3.6-flash-low",
87
- "gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
88
- "gemini-3.6-flash-high": "gemini-3.6-flash-high",
89
116
  "gemini-3.1-pro-low": "gemini-3.1-pro-low",
90
117
  "gemini-pro-agent": "gemini-pro-agent",
91
- "gemini-3.5-flash-extra-low": "gemini-3.6-flash-low",
92
- "gemini-3.5-flash-low": "gemini-3.6-flash-medium",
93
- "gemini-3.5-flash-mid": "gemini-3.6-flash-medium",
94
- "gemini-3.5-flash-high": "gemini-3.6-flash-high",
95
- "gemini-3-flash-agent": "gemini-3.6-flash-high",
118
+ // ── Retired Flash generations ──
119
+ // Google takes the previous Antigravity Flash model offline almost immediately
120
+ // once its successor ships, so 3.6 (and the 3.5 ids that used to land on it)
121
+ // route to 3.7. These stay in the alias map — not only in the tier map below —
122
+ // because `parseAntigravityAvailableModels` uses THIS map to keep a stale CCA
123
+ // payload from republishing a dead wire id as a picker row.
124
+ ...Object.fromEntries(
125
+ Object.keys(RETIRED_FLASH_TIERS).map(retired => [retired, GEMINI_FLASH_CURRENT]),
126
+ ),
96
127
  };
97
128
 
98
129
  export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
@@ -102,7 +133,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record<string, string> = {
102
133
 
103
134
  // Picker-visible: collapsed base models only.
104
135
  export const ANTIGRAVITY_MODELS = [
105
- "gemini-3.6-flash",
136
+ GEMINI_FLASH_CURRENT,
106
137
  "gemini-3.1-pro",
107
138
  "gemini-3.1-flash-image",
108
139
  "claude-sonnet-4-6",
@@ -112,9 +143,7 @@ export const ANTIGRAVITY_MODELS = [
112
143
 
113
144
  // Context windows from the upstream `:fetchAvailableModels` maxTokens per model.
114
145
  const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
115
- "gemini-3.6-flash-low": 1_048_576,
116
- "gemini-3.6-flash-medium": 1_048_576,
117
- "gemini-3.6-flash-high": 1_048_576,
146
+ "gemini-3.7-flash": 1_048_576,
118
147
  "gemini-3.1-pro-low": 1_048_576,
119
148
  "gemini-pro-agent": 1_048_576,
120
149
  "gemini-3.1-flash-image": 1_048_576,
@@ -125,7 +154,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
125
154
 
126
155
  export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
127
156
  // Collapsed base IDs — explicit entries for the picker.
128
- "gemini-3.6-flash": 1_048_576,
157
+ "gemini-3.7-flash": 1_048_576,
129
158
  "gemini-3.1-pro": 1_048_576,
130
159
  // Wire IDs and aliases via derivation.
131
160
  ...ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS,
@@ -138,7 +167,11 @@ export const ANTIGRAVITY_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
138
167
  };
139
168
 
140
169
  export const ANTIGRAVITY_MODEL_INPUT_MODALITIES: Record<string, string[]> = {
141
- "gemini-3.6-flash": ["text", "image"],
170
+ // Google documents 3.7 Flash as also accepting video, audio and PDF, but this proxy
171
+ // carries only text and image parts (`OcxImageContent`, src/types.ts) and the Codex
172
+ // catalog normalizes `input_modalities` against a closed enum. Advertising a modality
173
+ // the wire cannot carry would be a promise we break at request time.
174
+ "gemini-3.7-flash": ["text", "image"],
142
175
  "gemini-3.1-pro": ["text", "image"],
143
176
  "gemini-3.1-flash-image": ["text", "image"],
144
177
  "claude-sonnet-4-6": ["text", "image"],
@@ -244,6 +277,11 @@ export function isAntigravitySuffixModelId(modelId: string): boolean {
244
277
  return !(ANTIGRAVITY_MODELS as string[]).includes(modelId);
245
278
  }
246
279
 
280
+ /** The reasoning tier a retired Flash id used to encode, if it is one. */
281
+ export function retiredAntigravityFlashTier(modelId: string): string | undefined {
282
+ return RETIRED_FLASH_TIERS[modelId];
283
+ }
284
+
247
285
  /**
248
286
  * Resolve a picker-visible base model + optional reasoning effort to the CCA wire model ID.
249
287
  *
@@ -258,11 +296,32 @@ export function resolveAntigravityEffortWireModel(
258
296
  modelId: string,
259
297
  effort?: string,
260
298
  ): { wireModelId: string; thinkingLevel?: string } {
299
+ // Rule 0: retired Flash id — Google has taken the wire id offline, so route to the
300
+ // current generation and carry the tier the retired id encoded. This runs BEFORE the
301
+ // suffix check because those ids are aliases, and rule 1 would drop the tier.
302
+ const retiredTier = RETIRED_FLASH_TIERS[modelId];
303
+ if (retiredTier) {
304
+ return {
305
+ wireModelId: GEMINI_FLASH_CURRENT,
306
+ thinkingLevel: effort ? resolveAntigravityThinkingLevel(effort) ?? retiredTier : retiredTier,
307
+ };
308
+ }
309
+
261
310
  // Rule 1: suffix/compat alias — suffix IS the effort.
262
311
  if (isAntigravitySuffixModelId(modelId)) {
263
312
  return { wireModelId: resolveAntigravityWireModelId(modelId) };
264
313
  }
265
314
 
315
+ // Rule 1b: single-wire-id Gemini model whose tiers ride on thinkingLevel. Without
316
+ // this the model falls to rule 5 and silently loses reasoning control entirely.
317
+ const defaultLevel = ANTIGRAVITY_THINKING_LEVEL_MODELS[modelId];
318
+ if (defaultLevel) {
319
+ return {
320
+ wireModelId: modelId,
321
+ thinkingLevel: effort ? resolveAntigravityThinkingLevel(effort) ?? defaultLevel : defaultLevel,
322
+ };
323
+ }
324
+
266
325
  // Rule 2/3: mapped Gemini base model.
267
326
  const effortMap = ANTIGRAVITY_EFFORT_WIRE_MAP[modelId];
268
327
  if (effortMap) {
@@ -301,6 +360,12 @@ const ANTIGRAVITY_USAGE_BASE_BY_ID: Record<string, string> = (() => {
301
360
  // If alias is itself a base/wire already mapped, keep that mapping.
302
361
  else if (rev[wire]) rev[alias] = rev[wire]!;
303
362
  }
363
+ // Retired ids keep their OWN identity for usage aggregation, overriding the alias
364
+ // pass above. Routing sends new 3.6 calls to 3.7, but a usage row written months ago
365
+ // records a model the user actually called: relabelling it would move historical spend
366
+ // onto a model that did not exist then, and away from the 3.6 price row that still
367
+ // prices it correctly. Retirement changes what we CALL, not what we RECORD.
368
+ for (const retired of Object.keys(RETIRED_FLASH_TIERS)) rev[retired] = retired;
304
369
  // Visible aliases that only appear in ANTIGRAVITY_VISIBLE_MODEL_ALIASES are already
305
370
  // included via ANTIGRAVITY_MODEL_ALIASES. Identity bases without effort maps remain.
306
371
  return rev;
@@ -62,3 +62,13 @@ export function matchBaseUrlChoice(
62
62
  }
63
63
  return choices.some(c => c.id === "custom") ? "custom" : choices[0]!.id;
64
64
  }
65
+
66
+ /** Moonshot/Kimi API endpoint presets (international default; China selectable). */
67
+ export const MOONSHOT_INTL_BASE_URL = "https://api.moonshot.ai/v1";
68
+ export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
69
+
70
+ export const MOONSHOT_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
71
+ { id: "international", label: "International (.ai)", baseUrl: MOONSHOT_INTL_BASE_URL },
72
+ { id: "china", label: "China (.cn)", baseUrl: MOONSHOT_CN_BASE_URL },
73
+ { id: "custom", label: "Custom" },
74
+ ];
@@ -13,6 +13,24 @@ const COMMAND_CODE_MODEL_EFFORTS = {
13
13
  efforts: ["high", "max"],
14
14
  profileUrl: "https://commandcode.ai/models/glm-5-2",
15
15
  },
16
+ // Muse Spark: CLI currently prints "has no adjustable reasoning effort" and
17
+ // blocks --effort locally, but the upstream /alpha/generate endpoint accepts
18
+ // reasoning_effort low..max for meta/muse-spark-1.2-contributor (verified
19
+ // 2026-08-13: direct upstream POST with low/medium/high/xhigh/max all 200,
20
+ // ultra 400; reasoningTokens differentiated 114..253; proxy previously stripped
21
+ // the field so effort changes had no effect).
22
+ "meta/muse-spark-1.2": {
23
+ efforts: ["low", "medium", "high", "xhigh", "max"],
24
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.2",
25
+ },
26
+ "meta/muse-spark-1.2-contributor": {
27
+ efforts: ["low", "medium", "high", "xhigh", "max"],
28
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.2-contributor",
29
+ },
30
+ "meta/muse-spark-1.1": {
31
+ efforts: ["low", "medium", "high", "xhigh", "max"],
32
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.1",
33
+ },
16
34
  } as const;
17
35
 
18
36
  /**
@@ -0,0 +1,255 @@
1
+ // Registry model renames do not reach a saved provider config on their own.
2
+ //
3
+ // `reconcileOAuthProviders` refuses to touch a row whose `authMode` is not
4
+ // `oauth` (src/oauth/index.ts), and `enrichProviderFromRegistry` is fill-only by
5
+ // design: it backfills a MISSING field and never rewrites a present one, so a
6
+ // user's hand-edited model list survives an upgrade. Both postures are correct.
7
+ // Their gap is the case where the registry did not ADD a model but RENAMED one:
8
+ // the saved row keeps a retired id forever, the supported id never appears, and
9
+ // the capability metadata stays keyed to an id the vendor is taking offline
10
+ // (issue #1610 — `qwen3.8-max-preview` persisted through the `qwen3.8-max`
11
+ // rename in six separate fields, including a reasoning ladder that had since
12
+ // diverged from the registry's).
13
+ //
14
+ // This migration is deliberately NOT general reconciliation. It rewrites exactly
15
+ // one thing: an id this file declares retired, on a provider that still carries
16
+ // the registry's transport, and only when the registry currently seeds the
17
+ // replacement. Everything else in the row is left alone.
18
+
19
+ import { PROVIDER_REGISTRY } from "./registry";
20
+ import type { OcxConfig, OcxProviderConfig } from "../types";
21
+
22
+ export interface ModelRename {
23
+ /** Registry provider id whose saved rows may carry the retired model id. */
24
+ provider: string;
25
+ from: string;
26
+ to: string;
27
+ /** Why the vendor retired it, for the startup warning and future readers. */
28
+ reason: string;
29
+ /**
30
+ * Drop the retired key from `modelReasoningEffortMap` instead of renaming it.
31
+ *
32
+ * Renaming preserves the VALUE, which is right for records whose values describe the
33
+ * model — a context window or an effort ladder survives a rename — and wrong for the
34
+ * one record whose values are themselves wire ids. An effort map saved as
35
+ * `high -> gemini-3.6-flash-high` would keep naming a dead wire id under the new key,
36
+ * and the adapter maps effort BEFORE resolving CCA routing, so that value arrives as
37
+ * an unrecognised effort and silently degrades to the default tier.
38
+ *
39
+ * Only that one record is dropped. Emptying the others would be worse than the bug:
40
+ * catalog enrichment treats an existing `{}` as "already populated" and will not
41
+ * restore the registry's records, so the migrated user would keep routing correctly
42
+ * but lose the reasoning picker entirely.
43
+ */
44
+ dropReasoningEffortMap?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Renames already applied to `PROVIDER_REGISTRY`. An entry stays here after the
49
+ * registry moves on: it is what repairs configs saved before that move. Removing
50
+ * one strands every config that has not started since the rename shipped.
51
+ */
52
+ export const MODEL_RENAMES: readonly ModelRename[] = [
53
+ {
54
+ provider: "alibaba-token-plan",
55
+ from: "qwen3.8-max-preview",
56
+ to: "qwen3.8-max",
57
+ reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes",
58
+ },
59
+ {
60
+ provider: "alibaba-token-plan-intl",
61
+ from: "qwen3.8-max-preview",
62
+ to: "qwen3.8-max",
63
+ reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes",
64
+ },
65
+ // Antigravity Flash generations. Google takes the previous Flash model off Cloud Code
66
+ // Assist almost immediately when the next ships, so a saved 3.6 (or older 3.5) id is a
67
+ // dead selection rather than a merely outdated one. Routing already redirects these ids
68
+ // at request time; this migration repairs the saved config so the picker, the allowlist
69
+ // and the capability maps stop naming a model the backend no longer serves.
70
+ ...(["gemini-3.6-flash", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-high",
71
+ "gemini-3.5-flash-extra-low", "gemini-3.5-flash-low", "gemini-3.5-flash-mid", "gemini-3.5-flash-high",
72
+ "gemini-3-flash-agent"] as const).map(from => ({
73
+ provider: "google-antigravity",
74
+ from,
75
+ to: "gemini-3.7-flash",
76
+ reason: "Google retires the previous Antigravity Flash generation from Cloud Code Assist when its successor ships, so the saved id no longer resolves to a live model",
77
+ // The retired Flash tiers were wire ids, so any saved per-model record keyed by one
78
+ // may also hold one as a value. 3.7 expresses tiers as thinkingLevel names instead.
79
+ dropReasoningEffortMap: true,
80
+ })),
81
+ ];
82
+
83
+ /** Provider fields that key metadata by model id. */
84
+ const MODEL_KEYED_RECORDS = [
85
+ "modelContextWindows",
86
+ "modelMaxOutputTokens",
87
+ "modelInputModalities",
88
+ "modelReasoningEfforts",
89
+ "modelDefaultReasoningEfforts",
90
+ "modelReasoningEffortMap",
91
+ ] as const;
92
+
93
+ /** Provider fields that are flat lists of model ids. */
94
+ const MODEL_ID_LISTS = [
95
+ "models",
96
+ // A retired id left here is worse than a stale label: `filterCatalogModels` treats
97
+ // `selectedModels` as an exact-match allowlist, so a user who allowlisted only the
98
+ // retired model gets NO replacement row at all — the model silently vanishes from
99
+ // their catalog instead of being renamed. OAuth reconciliation does not cover this
100
+ // field, so the rename has to.
101
+ "selectedModels",
102
+ "noVisionModels",
103
+ "noReasoningModels",
104
+ "noTemperatureModels",
105
+ "noTopPModels",
106
+ "noPenaltyModels",
107
+ "autoToolChoiceOnlyModels",
108
+ "preserveReasoningContentModels",
109
+ "thinkingBudgetModels",
110
+ "directReasoningEffortModels",
111
+ ] as const;
112
+
113
+ function renameInList(value: unknown, from: string, to: string): string[] | null {
114
+ if (!Array.isArray(value) || !value.includes(from)) return null;
115
+ const seen = new Set<string>();
116
+ const next: string[] = [];
117
+ // Rename in place to preserve ordering, and collapse a duplicate if the target
118
+ // id was already present alongside the retired one.
119
+ for (const entry of value) {
120
+ if (typeof entry !== "string") continue;
121
+ const mapped = entry === from ? to : entry;
122
+ if (seen.has(mapped)) continue;
123
+ seen.add(mapped);
124
+ next.push(mapped);
125
+ }
126
+ return next;
127
+ }
128
+
129
+ function renameInRecord(value: unknown, from: string, to: string): Record<string, unknown> | null {
130
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
131
+ const record = value as Record<string, unknown>;
132
+ if (!(from in record)) return null;
133
+ const next: Record<string, unknown> = {};
134
+ for (const [key, entry] of Object.entries(record)) {
135
+ const mapped = key === from ? to : key;
136
+ if (mapped in next) continue;
137
+ // An explicit entry already saved under the new id is the newer intent.
138
+ next[mapped] = key === from && to in record ? record[to] : entry;
139
+ }
140
+ return next;
141
+ }
142
+
143
+ /** Drop the retired key entirely, leaving any entry already saved under the new id. */
144
+ function dropFromRecord(value: unknown, from: string): Record<string, unknown> | null {
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
146
+ const record = value as Record<string, unknown>;
147
+ if (!(from in record)) return null;
148
+ const next: Record<string, unknown> = {};
149
+ for (const [key, entry] of Object.entries(record)) {
150
+ if (key === from) continue;
151
+ next[key] = entry;
152
+ }
153
+ return next;
154
+ }
155
+
156
+ /**
157
+ * `provider/model` rows in the top-level `disabledModels` list.
158
+ *
159
+ * The retired row is DROPPED rather than renamed: carrying its disabled state to
160
+ * the new id would hide the supported model behind a toggle the user set for a
161
+ * different model. An existing row for the new id is left untouched.
162
+ */
163
+ function renameDisabledModels(config: OcxConfig, rename: ModelRename): boolean {
164
+ const list = config.disabledModels;
165
+ if (!Array.isArray(list)) return false;
166
+ const retired = `${rename.provider}/${rename.from}`;
167
+ if (!list.includes(retired)) return false;
168
+ config.disabledModels = list.filter(entry => entry !== retired);
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Only migrate a row that still points at the registry's own endpoint. A user who
174
+ * repointed `baseUrl` at a different vendor owns their model ids.
175
+ */
176
+ function providerStillMatchesRegistry(name: string, prov: OcxProviderConfig): boolean {
177
+ const entry = PROVIDER_REGISTRY.find(row => row.id === name);
178
+ if (!entry) return false;
179
+ if (!prov.baseUrl || !entry.baseUrl) return true;
180
+ const choices = entry.baseUrlChoices?.map(choice => choice.baseUrl) ?? [];
181
+ const known = [entry.baseUrl, ...choices]
182
+ .filter((url): url is string => typeof url === "string")
183
+ .map(url => url.replace(/\/+$/, ""));
184
+ return known.includes(prov.baseUrl.replace(/\/+$/, ""));
185
+ }
186
+
187
+ /** Guard against a stale rename: only apply when the registry actually seeds `to`. */
188
+ function registrySeedsTarget(rename: ModelRename): boolean {
189
+ const entry = PROVIDER_REGISTRY.find(row => row.id === rename.provider);
190
+ return !!entry?.models?.includes(rename.to);
191
+ }
192
+
193
+ export interface ModelRenameProjection {
194
+ config: OcxConfig;
195
+ changed: boolean;
196
+ warnings: string[];
197
+ }
198
+
199
+ /**
200
+ * Pure projection: apply every applicable rename and report what changed. The
201
+ * caller decides whether to persist.
202
+ */
203
+ export function projectModelRenames(
204
+ config: OcxConfig,
205
+ renames: readonly ModelRename[] = MODEL_RENAMES,
206
+ ): ModelRenameProjection {
207
+ const warnings: string[] = [];
208
+ let changed = false;
209
+
210
+ for (const rename of renames) {
211
+ const prov = config.providers?.[rename.provider];
212
+ if (!prov) continue;
213
+ if (!registrySeedsTarget(rename)) {
214
+ warnings.push(
215
+ `registry no longer seeds "${rename.to}" for "${rename.provider}"; skipping the `
216
+ + `"${rename.from}" rename rather than writing an id the registry does not know.`,
217
+ );
218
+ continue;
219
+ }
220
+ if (!providerStillMatchesRegistry(rename.provider, prov)) continue;
221
+
222
+ // Provider config is a closed interface, so index through one unknown-cast
223
+ // view rather than casting at each assignment.
224
+ const row = prov as unknown as Record<string, unknown>;
225
+ let touched = false;
226
+ for (const field of MODEL_ID_LISTS) {
227
+ const next = renameInList(row[field], rename.from, rename.to);
228
+ if (!next) continue;
229
+ row[field] = next;
230
+ touched = true;
231
+ }
232
+ for (const field of MODEL_KEYED_RECORDS) {
233
+ const next = rename.dropReasoningEffortMap && field === "modelReasoningEffortMap"
234
+ ? dropFromRecord(row[field], rename.from)
235
+ : renameInRecord(row[field], rename.from, rename.to);
236
+ if (!next) continue;
237
+ row[field] = next;
238
+ touched = true;
239
+ }
240
+ if (prov.defaultModel === rename.from) {
241
+ prov.defaultModel = rename.to;
242
+ touched = true;
243
+ }
244
+ if (renameDisabledModels(config, rename)) touched = true;
245
+
246
+ if (touched) {
247
+ changed = true;
248
+ warnings.push(
249
+ `renamed "${rename.provider}/${rename.from}" to "${rename.to}" in the saved config: ${rename.reason}.`,
250
+ );
251
+ }
252
+ }
253
+
254
+ return { config, changed, warnings };
255
+ }