@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -14,6 +14,22 @@ import { loadAuthStore, peekAuthStore, peekOAuthRefreshIntent, readOAuthRefreshI
14
14
  import type { ProviderAccount } from "./types";
15
15
 
16
16
  export type OAuthAccountHealth =
17
+ | { status: "healthy" }
18
+ | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
19
+ | { status: "reauth_required"; reason: OAuthReauthReason }
20
+ | { status: "warning"; reason: "refresh_conflict" | "metadata_mismatch" | "stale_credentials" | "validation_pending" };
21
+
22
+ /** Why an OAuth account needs reauthentication. `verify_account` is distinct from a dead
23
+ * credential: the grant is alive but the provider blocks the account until a human
24
+ * verifies it, so a plain re-login without that verification will not help. */
25
+ export type OAuthReauthReason = "unauthorized" | "forbidden" | "refresh_failed" | "verify_account";
26
+
27
+ /**
28
+ * Codex-pool health: the same shape minus the provider-verification cause, which only
29
+ * the generic OAuth quarantine produces. Narrowing the return keeps Codex DTOs on
30
+ * `CodexAccountReauthReason` without a lossy re-narrow at every consumer.
31
+ */
32
+ export type CodexPoolAccountHealth =
17
33
  | { status: "healthy" }
18
34
  | { status: "cooldown"; until: string; reason: "rate_limit" | "quota" }
19
35
  | { status: "reauth_required"; reason: "unauthorized" | "forbidden" | "refresh_failed" }
@@ -24,6 +40,7 @@ export type OAuthHealthLabel =
24
40
  | "Rate limited"
25
41
  | "Quota limited"
26
42
  | "Reauthentication required"
43
+ | "Verification required"
27
44
  | "Refresh failed"
28
45
  | "Metadata mismatch"
29
46
  | "Credential conflict"
@@ -60,7 +77,7 @@ type OAuthWarningReason = "refresh_conflict" | "metadata_mismatch" | "stale_cred
60
77
 
61
78
  export function projectOAuthAccountHealth(input: {
62
79
  needsReauth?: boolean;
63
- reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
80
+ reauthReason?: OAuthReauthReason;
64
81
  cooldownUntilMs?: number;
65
82
  cooldownReason?: "rate_limit" | "quota";
66
83
  warningReason?: OAuthWarningReason;
@@ -96,6 +113,9 @@ function actionFor(provider: string, health: OAuthAccountHealth): string | undef
96
113
  }
97
114
  if (health.status === "reauth_required") {
98
115
  if (provider === "codex") return CODEX_REAUTH_ACTION;
116
+ if (health.reason === "verify_account") {
117
+ return `verify the account with the provider in a browser, then run \`ocx login ${provider}\``;
118
+ }
99
119
  return `run \`ocx login ${provider}\``;
100
120
  }
101
121
  if (health.status === "cooldown") {
@@ -115,7 +135,9 @@ export function oauthHealthLabel(health: OAuthAccountHealth): OAuthHealthLabel {
115
135
  case "cooldown":
116
136
  return health.reason === "rate_limit" ? "Rate limited" : "Quota limited";
117
137
  case "reauth_required":
118
- return health.reason === "refresh_failed" ? "Refresh failed" : "Reauthentication required";
138
+ if (health.reason === "refresh_failed") return "Refresh failed";
139
+ if (health.reason === "verify_account") return "Verification required";
140
+ return "Reauthentication required";
119
141
  case "warning":
120
142
  switch (health.reason) {
121
143
  case "validation_pending":
@@ -190,7 +212,9 @@ export function projectStoredOAuthAccountHealth(
190
212
  : null;
191
213
  return projectOAuthAccountHealth({
192
214
  needsReauth: account.needsReauth === true,
193
- reauthReason: account.needsReauth === true ? "refresh_failed" : undefined,
215
+ reauthReason: account.needsReauth === true
216
+ ? (account.needsReauthReason ?? "refresh_failed")
217
+ : undefined,
194
218
  cooldownUntilMs: anthropicSnap?.cooldownUntil,
195
219
  // Same mapping as the Codex pool's `cooldownReasonFromSource`: only a Retry-After is
196
220
  // request-rate throttling. A reset-derived cooldown means a usage window is spent, which
@@ -206,7 +230,7 @@ export function projectCodexAccountHealth(input: {
206
230
  needsReauth: boolean;
207
231
  reauthReason?: "unauthorized" | "forbidden" | "refresh_failed";
208
232
  now?: number;
209
- }): OAuthAccountHealth {
233
+ }): CodexPoolAccountHealth {
210
234
  // One read serves every verdict below. Each lookup re-reads and re-hardens the whole store
211
235
  // file, and the main account lives in the native Codex auth file rather than the pool store,
212
236
  // so a lookup for it could only ever miss.
@@ -250,13 +274,16 @@ export function projectCodexAccountHealth(input: {
250
274
  }
251
275
  const now = input.now ?? Date.now();
252
276
  const snap = getCodexAccountHealthSnapshot(input.accountId, now);
277
+ // Safe narrowing: every reason above is Codex-scoped (`verify_account` only enters
278
+ // through the generic OAuth quarantine path), so the shared projector cannot
279
+ // produce it here despite the wider return type.
253
280
  return projectOAuthAccountHealth({
254
281
  needsReauth,
255
282
  reauthReason: needsReauth ? (input.reauthReason ?? storedFailureReason ?? "refresh_failed") : undefined,
256
283
  cooldownUntilMs: snap?.cooldownUntil,
257
284
  cooldownReason: cooldownReasonFromSource(snap?.cooldownSource),
258
285
  now,
259
- });
286
+ }) as CodexPoolAccountHealth;
260
287
  }
261
288
 
262
289
  /**
@@ -52,7 +52,7 @@ import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
52
52
  import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry";
53
53
  import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
54
54
  import { resolveProviderTransport } from "../providers/xai-transport";
55
- import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
55
+ import { detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
56
56
  import { logOAuthEvent } from "./log";
57
57
  import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
58
58
  import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode, type OAuthLoginHint } from "./login-flow-state";
@@ -82,7 +82,9 @@ export {
82
82
  export { OAUTH_REFRESH_LOCK_WAIT_MS, peekAuthStore, peekOAuthRefreshIntent } from "./store";
83
83
  import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
84
84
 
85
- const REFRESH_SKEW_MS = 60_000;
85
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
86
+ import { newerClaudeCredential, captureAnthropicCredentialOwner } from "./anthropic-continuity";
87
+ import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
86
88
  export interface OAuthAccessSnapshot {
87
89
  provider: string;
88
90
  accountId: string;
@@ -169,7 +171,7 @@ export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string;
169
171
  const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
170
172
  const permanentRefreshFailures=new Map<string,number>();
171
173
  interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
172
- interface AnthropicRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
174
+ interface AnthropicRefreshDeps { resolveIdentity?: AnthropicIdentityResolver; intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
173
175
  interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
174
176
  function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
175
177
  function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
@@ -866,6 +868,7 @@ function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OA
866
868
  function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
867
869
  return {
868
870
  ...fresh,
871
+ ...mergeAnthropicIdentity(fresh, previous),
869
872
  // Shared: a refresh function returns "local-cli" only when the credential it hands back
870
873
  // still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable
871
874
  // CLI key). Relabelling that "oauth" would stop the next forced refresh from re-reading it.
@@ -880,12 +883,6 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
880
883
  }
881
884
  export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const writerGeneration=captureConfigGeneration();const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const row=getAccountCredentialWithStatus(provider,accountId);if(!row)throw new OAuthLoginRequiredError(provider);if(row.paused)throw new OAuthAccountPausedError();const stored=row.credential;const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh,deps.signal),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(error instanceof OAuthMutationBusyError){permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));throw error;}if(!terminal(error))throw error;const failedAt=now();permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),failedAt+XAI_PERMANENT_FAILURE_TTL_MS);sweepExpiredOnWrite(failedAt);await markAccountNeedsReauthIfGeneration(provider,accountId,generation,writerGeneration);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
882
885
 
883
- function newerClaudeCredential(stored: OAuthCredentials, now: number): OAuthCredentials | undefined {
884
- if (stored.source !== "local-cli") return undefined;
885
- const disk = detectClaudeCodeToken();
886
- if (!disk || disk.expires <= now + REFRESH_SKEW_MS) return undefined;
887
- return credentialGeneration(disk) !== credentialGeneration(stored) ? disk : undefined;
888
- }
889
886
 
890
887
  /**
891
888
  * Preserve an already-rotated Nous refresh token (RT-B) after a terminal refresh
@@ -939,33 +936,42 @@ export async function refreshAnthropicAccountWithLock(
939
936
  const now = deps.now ?? Date.now;
940
937
  const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
941
938
  try {
942
- const row = getAccountCredentialWithStatus(provider, accountId);
943
- if (!row) throw new OAuthLoginRequiredError(provider);
944
- if (row.paused) throw new OAuthAccountPausedError();
945
- const stored = row.credential;
946
- const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
939
+ const accountSet = getAccountSet(provider);
940
+ const account = accountSet?.accounts.find(candidate => candidate.id === accountId);
941
+ if (!accountSet || !account) throw new OAuthLoginRequiredError(provider);
942
+ if (account.paused) throw new OAuthAccountPausedError();
943
+ const stored = account.credential;
944
+ const owns = captureAnthropicCredentialOwner(accountSet, accountId);
947
945
  const generation = credentialGeneration(stored);
948
946
  let pendingIntent = readOAuthRefreshIntent(provider, accountId);
949
- const disk = newerClaudeCredential(stored, now());
950
- if (disk) {
951
- const outcome = await mergeAccountCredential(provider, accountId, disk, {
952
- expectedGeneration: credentialGeneration(stored),
947
+ const observed = await newerClaudeCredential(stored, now(), deps.signal, deps.resolveIdentity);
948
+ const assertOwner = (store: AuthStore, checkDisk = true) => {
949
+ if (observed.kind !== "absent" && deps.signal?.aborted) throw new OAuthTokenRefreshStaleError();
950
+ if (!owns(store, checkDisk && observed.kind !== "absent" ? observed.diskGeneration : undefined)) {
951
+ if (store[provider]?.accounts.find(a => a.id === accountId)?.paused) throw new OAuthAccountPausedError();
952
+ throw new OAuthTokenRefreshStaleError();
953
+ }
954
+ };
955
+ assertOwner({ [provider]: getAccountSet(provider)! }, observed.kind !== "adopt");
956
+ if (observed.kind === "adopt") {
957
+ const outcome = await mergeAccountCredential(provider, accountId, observed.credential, {
958
+ expectedGeneration: generation,
953
959
  afterPrePersistRead: deps.afterPrePersistRead,
960
+ assertOwnership: assertOwner,
954
961
  });
955
- if (outcome.superseded) {
956
- // The disk credential is already durable here, so cleanup is secondary: an unlink
957
- // failure must not mask a committed credential by throwing over the return below.
958
- if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
959
- if (outcome.stored.expires > now() + REFRESH_SKEW_MS) return outcome.stored.access;
960
- throw new OAuthLoginRequiredError(provider);
961
- }
962
+ // A superseding write did not adopt our candidate. Its pending intent remains owned by it.
963
+ if (outcome.superseded) throw new OAuthTokenRefreshStaleError();
962
964
  if (pendingIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, pendingIntent);
963
- return disk.access;
965
+ return observed.credential.access;
964
966
  }
967
+ // Unknown rotation may have consumed this refresh token. Refuse without replay or changing health.
968
+ if (observed.kind === "unresolved") throw new OAuthLoginRequiredError(provider);
965
969
  if (pendingIntent?.cleanupPending && pendingIntent.generation === generation) {
966
970
  resumeAnthropicRefreshIntentCleanup(provider, accountId, pendingIntent);
967
971
  pendingIntent = undefined;
968
972
  }
973
+ if (observed.kind === "different"
974
+ && (pendingIntent?.uncertain || pendingIntent?.generation === generation)) throw new OAuthLoginRequiredError(provider);
969
975
  if (!pendingIntent?.uncertain && pendingIntent?.generation === generation) {
970
976
  if (pendingIntent.staleOwner) throw new OAuthTokenRefreshStaleError();
971
977
  if (deps.replacedStaleFlight && pendingIntent.flightId === deps.replacedStaleFlight.flightId) {
@@ -1007,6 +1013,7 @@ export async function refreshAnthropicAccountWithLock(
1007
1013
  const outcome = await mergeAccountCredential(provider, accountId, fresh, {
1008
1014
  expectedGeneration: generation,
1009
1015
  afterPrePersistRead: deps.afterPrePersistRead,
1016
+ assertOwnership: store => { if (!owns(store, undefined, true)) throw new OAuthTokenRefreshStaleError(); },
1010
1017
  });
1011
1018
  if (outcome.superseded) {
1012
1019
  if (attemptIntent) clearAnthropicRefreshIntentBestEffort(provider, accountId, attemptIntent);
@@ -1717,7 +1724,7 @@ export async function runLogin(
1717
1724
  });
1718
1725
  } else {
1719
1726
  const saveOptions = {
1720
- preserveIdentityless: opts?.forceLogin === true,
1727
+ preserveIdentityless: opts?.forceLogin === true || (provider === "anthropic" && cred.source === "local-cli"),
1721
1728
  assertBeforePersist: deps.assertCurrentOwner,
1722
1729
  };
1723
1730
  if (shouldRollbackKiroAccounts && !deps.saveCredential) {
@@ -1799,6 +1806,7 @@ export interface OAuthAccountSummary {
1799
1806
  email?: string;
1800
1807
  active: boolean;
1801
1808
  needsReauth?: boolean;
1809
+ needsReauthReason?: "verify_account";
1802
1810
  expiresAt?: number;
1803
1811
  /**
1804
1812
  * Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
@@ -1835,6 +1843,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
1835
1843
  email: projectEmail(a.credential.email, maskEmails) ?? undefined,
1836
1844
  active: a.id === set.activeAccountId,
1837
1845
  ...(a.needsReauth ? { needsReauth: true } : {}),
1846
+ ...(a.needsReauth && a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
1838
1847
  expiresAt: a.credential.expires,
1839
1848
  // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
1840
1849
  // exposes a subscription tier today, so there is nothing truthful to put here; deriving one
@@ -128,3 +128,10 @@ export function detectClaudeCodeToken(): OAuthCredentials | null {
128
128
  if (!raw) return null;
129
129
  return parseClaudeOauthPayload(raw);
130
130
  }
131
+
132
+ /** Shared-token continuity for opaque Claude tokens; fully rotated pairs need authenticated account proof. */
133
+ export function hasClaudeCredentialContinuity(stored: OAuthCredentials, disk: OAuthCredentials): boolean {
134
+ const sameToken = (left: string, right: string): boolean =>
135
+ typeof left === "string" && left.trim().length > 0 && left === right;
136
+ return sameToken(stored.refresh, disk.refresh) || sameToken(stored.access, disk.access);
137
+ }
@@ -0,0 +1,2 @@
1
+ /** Refresh early enough to avoid dispatching a bearer that expires in flight. */
2
+ export const REFRESH_SKEW_MS = 60_000;
@@ -19,6 +19,7 @@
19
19
  * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` —
20
20
  * both append distinct identified accounts under multiauth.
21
21
  */
22
+ import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
22
23
  import { createHash, randomUUID } from "node:crypto";
23
24
  import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
24
25
  import { join } from "node:path";
@@ -34,6 +35,7 @@ import {
34
35
  } from "../lib/state-store-sweeper";
35
36
  import { validateCopilotApiBaseUrl } from "./github-copilot";
36
37
  import { validateDevinApiBaseUrl } from "./devin/api-base";
38
+ import { REFRESH_SKEW_MS } from "./refresh-policy";
37
39
  import { parseAnthropicAccountThreshold } from "./anthropic-account-threshold";
38
40
  import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
39
41
 
@@ -531,6 +533,8 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
531
533
  };
532
534
  if (typeof candidate.email === "string" && candidate.email.length > 0) normalized.email = candidate.email;
533
535
  if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
536
+ const identity = normalizeAnthropicIdentity(candidate.anthropicIdentity, candidate.access);
537
+ if (identity) normalized.anthropicIdentity = identity;
534
538
  if (isCredentialSource(candidate.source)) normalized.source = candidate.source;
535
539
  if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId;
536
540
  if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) {
@@ -629,6 +633,9 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
629
633
  if (candidate.paused === true) account.paused = true;
630
634
  const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
631
635
  if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
636
+ if (candidate.needsReauth === true && candidate.needsReauthReason === "verify_account") {
637
+ account.needsReauthReason = "verify_account";
638
+ }
632
639
  if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
633
640
  if (typeof candidate.loginId === "string"
634
641
  && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) {
@@ -862,9 +869,10 @@ export async function saveCredentialWithReceipt(
862
869
  ): Promise<OAuthCredentialWriteReceipt | null> {
863
870
  const safe = normalizeCredential(cred);
864
871
  if (!safe) return null;
865
- return await mutateStore(store => {
872
+ const receipt = await mutateStore(store => {
866
873
  const set = store[provider];
867
874
  const previousActiveAccountId = set?.activeAccountId;
875
+ const previousSelectionRevision = set?.selectionRevision;
868
876
  const previousAccounts = new Map(
869
877
  set?.accounts.map(account => [account.id, structuredClone(account)]) ?? [],
870
878
  );
@@ -876,11 +884,27 @@ export async function saveCredentialWithReceipt(
876
884
  const id = newAccountId(safe);
877
885
  store[provider] = { activeAccountId: id, accounts: [{ id, credential: safe, addedAt: Date.now() }] };
878
886
  accountId = id;
887
+ } else if (provider === "anthropic" && safe.source === "local-cli") {
888
+ const existing = set.accounts.find(a => matchesAnthropicImport(a.credential, safe));
889
+ if (existing) {
890
+ existing.credential = { ...safe, ...mergeAnthropicIdentity(safe, existing.credential),
891
+ accountId: safe.accountId ?? existing.credential.accountId, email: safe.email ?? existing.credential.email };
892
+ set.selectionRevision = previousSelectionRevision;
893
+ delete existing.needsReauth;
894
+ delete existing.needsReauthReason;
895
+ accountId = existing.id;
896
+ } else {
897
+ const id = distinctAccountId(safe, set.accounts);
898
+ set.accounts.push({ id, credential: safe, addedAt: Date.now() });
899
+ set.activeAccountId = id;
900
+ accountId = id;
901
+ }
879
902
  } else if (identity) {
880
903
  const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
881
904
  if (existing) {
882
905
  existing.credential = safe;
883
906
  delete existing.needsReauth;
907
+ delete existing.needsReauthReason;
884
908
  if (existing.paused !== true) set.activeAccountId = existing.id;
885
909
  accountId = existing.id;
886
910
  } else {
@@ -892,6 +916,7 @@ export async function saveCredentialWithReceipt(
892
916
  if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
893
917
  active.credential = safe;
894
918
  delete active.needsReauth;
919
+ delete active.needsReauthReason;
895
920
  accountId = active.id;
896
921
  } else {
897
922
  const id = distinctAccountId(safe, set.accounts);
@@ -911,6 +936,7 @@ export async function saveCredentialWithReceipt(
911
936
  if (active) {
912
937
  active.credential = safe;
913
938
  delete active.needsReauth;
939
+ delete active.needsReauthReason;
914
940
  accountId = active.id;
915
941
  } else {
916
942
  const id = distinctAccountId(safe, set.accounts);
@@ -944,6 +970,18 @@ export async function saveCredentialWithReceipt(
944
970
  receipt.selectionRevision = store[provider]?.selectionRevision;
945
971
  },
946
972
  });
973
+ // An explicit login retires that account's failover evidence: a cooldown recorded
974
+ // against the old grant must not hold out the fresh credential. Dynamic import
975
+ // keeps the store free of a static edge into the failover module, which reads the
976
+ // store itself.
977
+ try {
978
+ const { clearGenericFailoverHealthForAccount } = await import("./generic-account-failover");
979
+ clearGenericFailoverHealthForAccount(provider, receipt.accountId);
980
+ } catch {
981
+ // The credential write has already committed. A cleanup failure must not
982
+ // surface as a failed login, or the Kiro path rolls the new grant back.
983
+ }
984
+ return receipt;
947
985
  }
948
986
 
949
987
  /** Ordinary callers do not acquire rollback authority merely by saving a credential. */
@@ -1047,6 +1085,7 @@ export async function upsertCredentialByIdentity(
1047
1085
  if (existing && set) {
1048
1086
  existing.credential = safe;
1049
1087
  delete existing.needsReauth;
1088
+ delete existing.needsReauthReason;
1050
1089
  set.activeAccountId ??= existing.id;
1051
1090
  return "updated";
1052
1091
  }
@@ -1149,6 +1188,7 @@ export async function saveAccountCredential(
1149
1188
  account.credential = safe;
1150
1189
  if (opts.rotateLoginId) account.loginId = randomUUID();
1151
1190
  delete account.needsReauth;
1191
+ delete account.needsReauthReason;
1152
1192
  // A pause that found no usable fallback leaves the active id on a paused row. Once this
1153
1193
  // reauthentication makes an unpaused account usable, hand the selection to it.
1154
1194
  const active = set.accounts.find(a => a.id === set.activeAccountId);
@@ -1288,7 +1328,9 @@ export async function setAccountPaused(
1288
1328
  if (paused && set.activeAccountId === accountId) {
1289
1329
  const start = set.accounts.findIndex(candidate => candidate.id === accountId);
1290
1330
  const ring = [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)];
1291
- const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true);
1331
+ const fallback = ring.find(candidate => candidate.paused !== true && candidate.needsReauth !== true
1332
+ && !(provider === "anthropic" && candidate.credential.source === "local-cli"
1333
+ && candidate.credential.expires <= Date.now() + REFRESH_SKEW_MS));
1292
1334
  if (fallback) {
1293
1335
  set.activeAccountId = fallback.id;
1294
1336
  set.selectionRevision = randomUUID();
@@ -1363,6 +1405,7 @@ export async function replaceProviderAccountSet(
1363
1405
  ...(account.needsReauth ? { needsReauth: true } : {}),
1364
1406
  ...(account.paused ? { paused: true } : {}),
1365
1407
  ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
1408
+ ...(account.needsReauthReason === "verify_account" ? { needsReauthReason: account.needsReauthReason } : {}),
1366
1409
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
1367
1410
  ...(account.loginId ? { loginId: account.loginId } : {}),
1368
1411
  })),
@@ -1408,18 +1451,24 @@ export async function markAccountNeedsReauth(
1408
1451
  provider: string,
1409
1452
  accountId: string,
1410
1453
  needsReauth: boolean,
1454
+ reason?: "verify_account",
1411
1455
  writerGeneration = captureConfigGeneration(),
1412
1456
  ): Promise<void> {
1413
1457
  if (writerGeneration < lastReconciledGeneration && !liveOAuthAccountKeys.has(oauthAccountKey(provider, accountId))) return;
1414
1458
  await mutateStore(store => {
1415
1459
  const account = store[provider]?.accounts.find(a => a.id === accountId);
1416
1460
  if (!account) return;
1417
- if (needsReauth) account.needsReauth = true;
1418
- else delete account.needsReauth;
1461
+ if (needsReauth) {
1462
+ account.needsReauth = true;
1463
+ if (reason === "verify_account") account.needsReauthReason = reason;
1464
+ } else {
1465
+ delete account.needsReauth;
1466
+ delete account.needsReauthReason;
1467
+ }
1419
1468
  }, [provider, accountId]);
1420
1469
  }
1421
1470
 
1422
- export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.needsReauth;return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
1471
+ export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
1423
1472
  // A late refresh failure must not change an operator-paused account's health. Check under
1424
1473
  // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
1425
- export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration()):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;return true;},[provider,accountId,generation]);}
1474
+ export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account"):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
@@ -62,6 +62,8 @@ export type OAuthCredentials = {
62
62
  email?: string;
63
63
  accountId?: string;
64
64
  source?: OAuthCredentialSource;
65
+ /** Private authenticated account proof; never projected into account summaries. */
66
+ anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string };
65
67
  /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */
66
68
  projectId?: string;
67
69
  /**
@@ -92,6 +94,13 @@ export interface ProviderAccount {
92
94
  paused?: boolean;
93
95
  /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
94
96
  autoSwitchThresholdOverride?: number;
97
+ /**
98
+ * Why the account needs reauthentication. `verify_account` means the grant is
99
+ * alive but the provider blocks the account until the human verifies it
100
+ * (Antigravity 403 PERMISSION_DENIED) — a plain re-login without that
101
+ * verification will not help. Absent means an ordinary credential failure.
102
+ */
103
+ needsReauthReason?: "verify_account";
95
104
  addedAt?: number;
96
105
  }
97
106
 
@@ -386,7 +386,8 @@ function createAnthropicMessageWriter(
386
386
  return;
387
387
  }
388
388
  const message = typeof error.message === "string" ? error.message : "upstream request failed";
389
- fail(anthropicFailedStatus(error, message), message, true);
389
+ fail(anthropicFailedStatus(error, message), message, true,
390
+ error.code === "context_length_exceeded" ? error.code : undefined);
390
391
  },
391
392
  overflow() {
392
393
  fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit");
@@ -413,7 +414,7 @@ export function encodeAnthropicMessageSse(
413
414
  /**
414
415
  * Fold an encoded Messages stream into the non-streaming client response, with the status
415
416
  * mapping the Messages ingress applied to its collected message: a translator overflow is 413,
416
- * a stream that ended in an error frame is 502, anything else unexpected a 502 api_error.
417
+ * a classified context rejection is 400; other error frames/unexpected failures stay 502.
417
418
  */
418
419
  export async function collectAnthropicMessageResponse(
419
420
  stream: ReadableStream<Uint8Array>,
@@ -444,7 +445,7 @@ export async function collectAnthropicMessageResponse(
444
445
  );
445
446
  }
446
447
  return new Response(JSON.stringify(message), {
447
- status: isError ? 502 : 200,
448
+ status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200,
448
449
  headers: { "Content-Type": "application/json" },
449
450
  });
450
451
  }
@@ -0,0 +1,75 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { routedSlug, slugEquals, slugEquivalenceKey } from "./slug-codec";
3
+
4
+ /** Discovery evidence only; independent of the parser and the catalog cache. */
5
+ export interface AntigravityEffortFamilyRow {
6
+ id: string;
7
+ provider: string;
8
+ custom?: boolean;
9
+ catalogKind?: string;
10
+ antigravityEffortWireModelIds?: Partial<Record<"low" | "medium" | "high", string>>;
11
+ }
12
+
13
+ const FAMILY_EFFORTS = ["low", "medium", "high"] as const;
14
+
15
+ export function antigravityEffortFamilyIds(row: AntigravityEffortFamilyRow): string[] | undefined {
16
+ if (row.custom || row.catalogKind === "custom-model-v1" || !row.antigravityEffortWireModelIds) return undefined;
17
+ const ids = FAMILY_EFFORTS.map(effort => `${row.id}-${effort}`);
18
+ return FAMILY_EFFORTS.every((effort, index) => row.antigravityEffortWireModelIds![effort] === ids[index])
19
+ ? ids : undefined;
20
+ }
21
+
22
+ /** Retained wire targets stay routable internally; only the public list collapses. */
23
+ export function collapseAntigravityPublicModels<T extends AntigravityEffortFamilyRow>(rows: T[]): T[] {
24
+ const hidden = new Map<string, Set<string>>();
25
+ for (const row of rows) {
26
+ const ids = antigravityEffortFamilyIds(row);
27
+ if (!ids) continue;
28
+ const providerIds = hidden.get(row.provider) ?? new Set<string>();
29
+ for (const id of ids) providerIds.add(id);
30
+ hidden.set(row.provider, providerIds);
31
+ }
32
+ return rows.filter(row => row.custom || row.catalogKind === "custom-model-v1"
33
+ || antigravityEffortFamilyIds(row) !== undefined || !hidden.get(row.provider)?.has(row.id));
34
+ }
35
+
36
+ /** Project stored native/encoded selections without rewriting the user's allowlist. */
37
+ export function projectAntigravitySelectedModels(
38
+ provider: string,
39
+ selected: readonly string[],
40
+ rows: readonly AntigravityEffortFamilyRow[],
41
+ ): string[] {
42
+ const projected = [...selected];
43
+ const keys = new Set(selected.map(id => slugEquivalenceKey(routedSlug(provider, id))));
44
+ const bases = new Set(rows.filter(row => row.provider === provider && antigravityEffortFamilyIds(row)).map(row => row.id));
45
+ for (const row of rows) {
46
+ if (row.provider !== provider) continue;
47
+ const ids = antigravityEffortFamilyIds(row);
48
+ const baseKey = slugEquivalenceKey(routedSlug(provider, row.id));
49
+ if (!ids || keys.has(baseKey) || projected.includes(row.id)
50
+ || !ids.some(id => !bases.has(id) && keys.has(slugEquivalenceKey(routedSlug(provider, id))))) continue;
51
+ projected.push(row.id);
52
+ }
53
+ return projected;
54
+ }
55
+
56
+ /** Inherit once, before the baseline has ever recorded the public base identity. */
57
+ export function antigravityFamilyDisabled(
58
+ config: Pick<OcxConfig, "providers" | "disabledModels" | "modelDiscovery">,
59
+ row: AntigravityEffortFamilyRow,
60
+ rows: readonly AntigravityEffortFamilyRow[] = [],
61
+ ): boolean {
62
+ const ids = antigravityEffortFamilyIds(row);
63
+ if (!ids) return false;
64
+ const disabled = config.disabledModels ?? [];
65
+ if (disabled.some(slug => slugEquals(slug, row.provider, row.id))) return true;
66
+ const baseline = config.modelDiscovery?.knownModels?.[row.provider];
67
+ if (!baseline) return false;
68
+ const known = new Set([...baseline.ids, ...baseline.removed]);
69
+ if (known.has(row.id)) return false;
70
+ const bases = new Set(rows.filter(candidate => candidate.provider === row.provider
71
+ && antigravityEffortFamilyIds(candidate)).map(candidate => candidate.id));
72
+ const priorTiers = ids.filter(id => known.has(id) && !bases.has(id));
73
+ return priorTiers.length > 0
74
+ && priorTiers.every(id => disabled.some(slug => slugEquals(slug, row.provider, id)));
75
+ }