@bitkyc08/opencodex 2.39.0 → 2.40.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 (144) hide show
  1. package/README.md +49 -21
  2. package/gui/dist/assets/index-BHe2rl_C.js +112 -0
  3. package/gui/dist/assets/index-CJSb3HPe.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/cursor/catalog.ts +140 -7
  7. package/src/adapters/cursor/discovery.ts +79 -87
  8. package/src/adapters/cursor/effort-map.ts +11 -0
  9. package/src/adapters/cursor/request-builder.ts +31 -4
  10. package/src/adapters/cursor.ts +17 -0
  11. package/src/adapters/openai-chat.ts +95 -9
  12. package/src/adapters/openai-responses.ts +73 -9
  13. package/src/bridge.ts +58 -13
  14. package/src/claude/auth-mode.ts +6 -9
  15. package/src/claude/gateway-cache.ts +23 -3
  16. package/src/claude/model-info.ts +13 -2
  17. package/src/cli/access.ts +30 -0
  18. package/src/cli/account-extended.ts +19 -10
  19. package/src/cli/capabilities.ts +40 -1
  20. package/src/cli/claude-agent-startup-sync.ts +3 -0
  21. package/src/cli/claude.ts +134 -36
  22. package/src/cli/connect.ts +226 -0
  23. package/src/cli/dispatch.ts +126 -21
  24. package/src/cli/gui-pair-client.ts +170 -0
  25. package/src/cli/gui.ts +87 -0
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/index.ts +76 -22
  28. package/src/cli/models-runtime-subcommands.ts +34 -0
  29. package/src/cli/models-runtime.ts +4 -0
  30. package/src/cli/models.ts +2 -1
  31. package/src/cli/opencode.ts +2 -1
  32. package/src/cli/provider-runtime.ts +31 -1
  33. package/src/cli/registry.ts +32 -2
  34. package/src/cli/runtime-api.ts +10 -1
  35. package/src/cli/status.ts +26 -0
  36. package/src/cli/system-command.ts +9 -3
  37. package/src/client/connect.ts +650 -0
  38. package/src/client/hub-client.ts +457 -0
  39. package/src/client/hub-relay.ts +288 -0
  40. package/src/client/machine-api.ts +139 -0
  41. package/src/client/machine-auth.ts +54 -0
  42. package/src/client/machine-listener.ts +143 -0
  43. package/src/client/runtime.ts +93 -0
  44. package/src/client/state.ts +175 -0
  45. package/src/clients/config-export.ts +16 -5
  46. package/src/codex/auth-api.ts +169 -15
  47. package/src/codex/auth-context.ts +61 -6
  48. package/src/codex/catalog/aggregation.ts +17 -13
  49. package/src/codex/catalog/metadata.ts +28 -0
  50. package/src/codex/catalog/provider-fetch.ts +38 -5
  51. package/src/codex/catalog.ts +1 -1
  52. package/src/codex/desired-state.ts +13 -3
  53. package/src/codex/inject.ts +212 -31
  54. package/src/codex/journal.ts +54 -6
  55. package/src/codex/main-account.ts +105 -33
  56. package/src/codex/native-main-claim.ts +23 -1
  57. package/src/codex/native-residue.ts +7 -0
  58. package/src/codex/reset-credit-auto-redeem.ts +237 -0
  59. package/src/combos/request.ts +15 -3
  60. package/src/combos/types.ts +12 -0
  61. package/src/config/paths.ts +15 -1
  62. package/src/config/provider-validation.ts +40 -0
  63. package/src/config.ts +447 -3
  64. package/src/generated/compatibility-version.json +224 -108
  65. package/src/images/artifacts.ts +73 -37
  66. package/src/images/fulfill.ts +6 -1
  67. package/src/images/index.ts +1 -1
  68. package/src/images/plan.ts +16 -2
  69. package/src/images/synthetic-tool.ts +5 -0
  70. package/src/images/xai-client.ts +34 -1
  71. package/src/integrations/cursor-detect.ts +133 -0
  72. package/src/integrations/cursor-seen.ts +31 -0
  73. package/src/lib/errors.ts +18 -0
  74. package/src/lib/gui-pair-capability.ts +104 -0
  75. package/src/lib/lab-live-route-production.ts +2 -1
  76. package/src/lib/process-control.ts +4 -3
  77. package/src/lib/service-secrets.ts +161 -1
  78. package/src/lib/windows-system-proxy.ts +115 -0
  79. package/src/oauth/index.ts +3 -2
  80. package/src/oauth/pool-settings-capability.ts +55 -0
  81. package/src/providers/api-keys.ts +3 -1
  82. package/src/providers/codex-capacity.ts +34 -4
  83. package/src/providers/derive.ts +10 -0
  84. package/src/providers/fastwire.ts +10 -2
  85. package/src/providers/key-store.ts +197 -0
  86. package/src/providers/model-rename-migration.ts +3 -0
  87. package/src/providers/openai-sidecar.ts +3 -3
  88. package/src/providers/quota.ts +91 -42
  89. package/src/providers/registry.ts +33 -3
  90. package/src/reasoning-effort.ts +32 -0
  91. package/src/remote/protocol.ts +109 -0
  92. package/src/responses/citation-markers.ts +101 -0
  93. package/src/responses/parser.ts +43 -3
  94. package/src/responses/spill-store.ts +9 -4
  95. package/src/router.ts +72 -8
  96. package/src/server/auth-cors.ts +37 -1
  97. package/src/server/gui-session.ts +449 -0
  98. package/src/server/gui-static.ts +24 -7
  99. package/src/server/images.ts +237 -10
  100. package/src/server/index.ts +356 -30
  101. package/src/server/management/agent-settings-routes.ts +7 -1
  102. package/src/server/management/api-key-rotation.ts +74 -0
  103. package/src/server/management/combo-routes.ts +18 -6
  104. package/src/server/management/config-routes.ts +22 -3
  105. package/src/server/management/context.ts +5 -1
  106. package/src/server/management/cursor-integration-routes.ts +98 -0
  107. package/src/server/management/logs-usage-routes.ts +2 -1
  108. package/src/server/management/model-routes.ts +79 -1
  109. package/src/server/management/model-rows.ts +23 -1
  110. package/src/server/management/oauth-account-routes.ts +135 -2
  111. package/src/server/management/provider-routes.ts +143 -9
  112. package/src/server/management/route-registry.ts +10 -0
  113. package/src/server/management/session-routes.ts +13 -0
  114. package/src/server/management/system-routes.ts +13 -1
  115. package/src/server/management-api.ts +8 -3
  116. package/src/server/management-auth.ts +124 -82
  117. package/src/server/models-capabilities.ts +124 -0
  118. package/src/server/ports.ts +7 -0
  119. package/src/server/proxy-liveness.ts +7 -0
  120. package/src/server/request-log.ts +11 -5
  121. package/src/server/responses/agent-task-recovery.ts +1 -0
  122. package/src/server/responses/codex-auth-error.ts +3 -1
  123. package/src/server/responses/collaboration.ts +26 -1
  124. package/src/server/responses/combo-stream-preflight.ts +25 -6
  125. package/src/server/responses/compact.ts +32 -12
  126. package/src/server/responses/context-overflow.ts +49 -0
  127. package/src/server/responses/core.ts +196 -12
  128. package/src/server/responses/fetch-helpers.ts +2 -1
  129. package/src/server/responses/outbound-body-guard.ts +110 -0
  130. package/src/server/responses/ws-upstream.ts +87 -6
  131. package/src/server/responses-self-named-namespace-scrub.ts +181 -0
  132. package/src/server/system-env.ts +78 -25
  133. package/src/service.ts +218 -42
  134. package/src/types/config.ts +134 -0
  135. package/src/types/provider.ts +54 -1
  136. package/src/types/request.ts +2 -0
  137. package/src/types.ts +5 -0
  138. package/src/update/job.ts +33 -5
  139. package/src/usage/cost.ts +32 -1
  140. package/src/usage/expected-prices.ts +16 -0
  141. package/src/usage/log.ts +6 -2
  142. package/src/usage/summary.ts +48 -6
  143. package/gui/dist/assets/index-D-lchsPw.js +0 -112
  144. package/gui/dist/assets/index-uvENYLin.css +0 -1
@@ -0,0 +1,197 @@
1
+ import { createRequire } from "node:module";
2
+ import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config";
3
+ import type { OcxConfig, OcxProviderConfig } from "../types";
4
+
5
+ /**
6
+ * Opt-in OS keychain storage for provider API keys (#1221).
7
+ *
8
+ * `config.json` keeps only a reference (`keychain:<provider>` for the active key,
9
+ * `keychain:<provider>/<pool id>` for pool entries); the secret lives in the OS credential store
10
+ * under one service name. Reads are synchronous on purpose: `routedProviderConfig` and the
11
+ * quota/compaction/catalog callers are all sync, and `@napi-rs/keyring` ships a sync `Entry`.
12
+ *
13
+ * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
14
+ * account; nothing ever rewrites plaintext into config or its backups. Opting in verifies the
15
+ * keychain by writing and reading back before the config is touched, so an unavailable store
16
+ * (headless service, locked session) refuses rather than half-migrating.
17
+ */
18
+
19
+ export const KEYCHAIN_REFERENCE_PREFIX = "keychain:";
20
+ export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1";
21
+
22
+ export interface ProviderKeychainEntry {
23
+ getPassword(): string | null;
24
+ setPassword(password: string): void;
25
+ deletePassword(): boolean;
26
+ }
27
+
28
+ export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry;
29
+
30
+ const nodeRequire = createRequire(import.meta.url);
31
+
32
+ function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry {
33
+ const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry };
34
+ return new Entry(service, account);
35
+ }
36
+
37
+ let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory;
38
+ const resolvedCache = new Map<string, string>();
39
+ const warnedAccounts = new Set<string>();
40
+
41
+ /** Test seam: swap the OS entry for an in-memory one and drop caches. */
42
+ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
43
+ entryFactory = factory ?? defaultEntryFactory;
44
+ resolvedCache.clear();
45
+ warnedAccounts.clear();
46
+ }
47
+
48
+ export function isKeychainReference(value: string | undefined): value is string {
49
+ return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length;
50
+ }
51
+
52
+ function keychainAccount(reference: string): string {
53
+ return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length);
54
+ }
55
+
56
+ function readKeychain(account: string): string | undefined {
57
+ const cached = resolvedCache.get(account);
58
+ if (cached !== undefined) return cached;
59
+ try {
60
+ const value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword();
61
+ if (typeof value === "string" && value.trim()) {
62
+ resolvedCache.set(account, value);
63
+ return value;
64
+ }
65
+ } catch {
66
+ // fall through to the single warning below
67
+ }
68
+ if (!warnedAccounts.has(account)) {
69
+ warnedAccounts.add(account);
70
+ console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`);
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ /**
76
+ * Single resolver for provider key material: env references, keychain references, or the
77
+ * literal value. Every request-time read of `apiKey` goes through here.
78
+ */
79
+ export function resolveProviderApiKey(value: string | undefined): string | undefined {
80
+ if (!value) return undefined;
81
+ if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
82
+ return resolveEnvValue(value);
83
+ }
84
+
85
+ export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
86
+
87
+ export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
88
+ const key = provider?.apiKey;
89
+ if (!key) return "none";
90
+ if (isKeychainReference(key)) return "keychain";
91
+ if (/^\$\{?\w+\}?$/.test(key)) return "env";
92
+ return "file";
93
+ }
94
+
95
+ /** Probe the OS keychain with a throwaway account: write, read back, delete. */
96
+ export function probeProviderKeychain(): { available: true } | { available: false; reason: string } {
97
+ const account = `probe-${process.pid}-${Date.now()}`;
98
+ try {
99
+ const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
100
+ entry.setPassword("ok");
101
+ const back = entry.getPassword();
102
+ try { entry.deletePassword(); } catch { /* best effort */ }
103
+ if (back !== "ok") return { available: false, reason: "keychain read-back did not match" };
104
+ return { available: true };
105
+ } catch (error) {
106
+ return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" };
107
+ }
108
+ }
109
+
110
+ function writeVerified(account: string, secret: string): void {
111
+ const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
112
+ entry.setPassword(secret);
113
+ if (entry.getPassword() !== secret) throw new Error(`keychain read-back mismatch for ${account}`);
114
+ }
115
+
116
+ /**
117
+ * Move a provider's active key and every plaintext pool entry into the OS keychain and rewrite
118
+ * config with references. All keychain writes are verified before config changes; on any
119
+ * failure the entries written so far are deleted and config is left untouched.
120
+ */
121
+ export function storeProviderKeyInKeychain(config: OcxConfig, name: string): { ok: true; moved: number } | { ok: false; error: string; status: number } {
122
+ const provider = config.providers[name];
123
+ if (!provider) return { ok: false, error: "unknown provider", status: 404 };
124
+ if (provider.authMode === "oauth" || provider.authMode === "forward") {
125
+ return { ok: false, error: "provider does not use API-key auth", status: 400 };
126
+ }
127
+ const probe = probeProviderKeychain();
128
+ if (!probe.available) return { ok: false, error: `OS keychain unavailable: ${probe.reason}`, status: 503 };
129
+
130
+ const written: string[] = [];
131
+ const planned: Array<() => void> = [];
132
+ const pool = provider.apiKeyPool ?? [];
133
+ try {
134
+ for (const entry of pool) {
135
+ if (isKeychainReference(entry.key)) continue;
136
+ const secret = resolveEnvValue(entry.key);
137
+ if (!secret) continue; // unresolved env reference stays as-is
138
+ const account = `${name}/${entry.id}`;
139
+ writeVerified(account, secret);
140
+ written.push(account);
141
+ planned.push(() => { entry.key = `${KEYCHAIN_REFERENCE_PREFIX}${account}`; });
142
+ }
143
+ if (provider.apiKey && !isKeychainReference(provider.apiKey)) {
144
+ const active = pool.find(e => e.key === provider.apiKey || (isKeychainReference(e.key) && false));
145
+ const secret = resolveEnvValue(provider.apiKey);
146
+ if (secret) {
147
+ if (active) {
148
+ // Mirror the pool reference so failover keeps comparing equal strings.
149
+ planned.push(() => { provider.apiKey = `${KEYCHAIN_REFERENCE_PREFIX}${name}/${active.id}`; });
150
+ } else {
151
+ writeVerified(name, secret);
152
+ written.push(name);
153
+ planned.push(() => { provider.apiKey = `${KEYCHAIN_REFERENCE_PREFIX}${name}`; });
154
+ }
155
+ }
156
+ }
157
+ } catch (error) {
158
+ for (const account of written) {
159
+ try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ }
160
+ }
161
+ return { ok: false, error: `OS keychain write failed: ${error instanceof Error ? error.message : "unknown"}`, status: 503 };
162
+ }
163
+ for (const apply of planned) apply();
164
+ resolvedCache.clear();
165
+ warnedAccounts.clear();
166
+ saveConfigPreservingClaudeCode(config);
167
+ return { ok: true, moved: written.length };
168
+ }
169
+
170
+ /** Reverse of `storeProviderKeyInKeychain`: read every reference back, write plaintext, delete items. */
171
+ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): { ok: true; restored: number } | { ok: false; error: string; status: number } {
172
+ const provider = config.providers[name];
173
+ if (!provider) return { ok: false, error: "unknown provider", status: 404 };
174
+ const pool = provider.apiKeyPool ?? [];
175
+ const resolved = new Map<string, string>();
176
+ const refs = [provider.apiKey, ...pool.map(e => e.key)].filter(isKeychainReference);
177
+ for (const ref of refs) {
178
+ const account = keychainAccount(ref);
179
+ if (resolved.has(account)) continue;
180
+ let value: string | null = null;
181
+ try { value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); } catch { value = null; }
182
+ if (!value) return { ok: false, error: `OS keychain has no readable secret for ${ref}; config left unchanged`, status: 503 };
183
+ resolved.set(account, value);
184
+ }
185
+ for (const entry of pool) {
186
+ if (isKeychainReference(entry.key)) entry.key = resolved.get(keychainAccount(entry.key))!;
187
+ }
188
+ if (isKeychainReference(provider.apiKey)) provider.apiKey = resolved.get(keychainAccount(provider.apiKey))!;
189
+ for (const account of resolved.keys()) {
190
+ try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ }
191
+ }
192
+ resolvedCache.clear();
193
+ warnedAccounts.clear();
194
+ saveConfigPreservingClaudeCode(config);
195
+ return { ok: true, restored: resolved.size };
196
+ }
197
+
@@ -99,6 +99,9 @@ const MODEL_ID_LISTS = [
99
99
  // their catalog instead of being renamed. OAuth reconciliation does not cover this
100
100
  // field, so the rename has to.
101
101
  "selectedModels",
102
+ // Same reasoning as `selectedModels`: a retired id pinned here would be resurrected as a
103
+ // ghost row on every discovery instead of following the rename (#1690).
104
+ "retainModels",
102
105
  "noVisionModels",
103
106
  "noReasoningModels",
104
107
  "noTemperatureModels",
@@ -1,4 +1,4 @@
1
- import { resolveEnvValue } from "../config";
1
+ import { resolveProviderApiKey } from "./key-store";
2
2
  import {
3
3
  CodexPoolAuthenticationError,
4
4
  headersForCodexAuthContext,
@@ -198,7 +198,7 @@ export function selectOpenAiImagesProvider(config: OcxConfig): OpenAiImagesProvi
198
198
  && provider.authMode !== "forward"
199
199
  && provider.baseUrl.replace(/\/+$/, "") === "https://api.openai.com/v1"
200
200
  ) {
201
- const apiKey = resolveEnvValue(provider.apiKey)?.trim();
201
+ const apiKey = resolveProviderApiKey(provider.apiKey)?.trim();
202
202
  if (apiKey) selection.keyed = { providerName: OPENAI_API_PROVIDER_ID, provider, apiKey };
203
203
  }
204
204
  return selection;
@@ -236,7 +236,7 @@ export function selectImagesProvider(config: OcxConfig): OpenAiImagesProviderSel
236
236
  };
237
237
  }
238
238
 
239
- const apiKey = resolveEnvValue(provider.apiKey)?.trim();
239
+ const apiKey = resolveProviderApiKey(provider.apiKey)?.trim();
240
240
  if (!apiKey) {
241
241
  return { forwardCandidates: [], error: `images.provider "${providerName}" has no usable API key` };
242
242
  }
@@ -9,10 +9,11 @@ import type { StoredAccountQuota } from "../codex/quota";
9
9
  import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
10
10
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
11
11
  import { codexPlanKey } from "../codex/plan";
12
- import { resolveEnvValue } from "../config";
12
+ import { resolveProviderApiKey } from "./key-store";
13
13
  import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
14
14
  import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
15
15
  import { antigravityUserAgent } from "../adapters/client-fingerprint";
16
+ import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
16
17
  import { apiKeyPoolEntryId } from "./api-keys";
17
18
  import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
18
19
  import { getProviderRegistryEntry, providerCodexAccountMode, registryEntryForProviderDestination } from "./registry";
@@ -153,7 +154,7 @@ function cacheKey(config: OcxConfig): string {
153
154
  const providers = Object.entries(config.providers)
154
155
  .map(([name, provider]) => {
155
156
  const resolvedKey = typeof provider.apiKey === "string"
156
- ? resolveEnvValue(provider.apiKey)?.trim()
157
+ ? resolveProviderApiKey(provider.apiKey)?.trim()
157
158
  : undefined;
158
159
  const activeKeyId = resolvedKey ? apiKeyPoolEntryId(resolvedKey) : "none";
159
160
  return `${name}:${provider.adapter}:${provider.authMode ?? "key"}:${providerCodexAccountMode(name, provider) ?? "none"}:${provider.disabled === true ? "off" : "on"}:${provider.baseUrl}:${activeKeyId}`;
@@ -370,7 +371,7 @@ function firstFinite(record: Record<string, unknown> | null, names: string[]): n
370
371
  async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
371
372
  // Never send a configured API key to a lookalike host or through a redirect.
372
373
  if (!isCanonicalA6apiBaseUrl(config.baseUrl)) return null;
373
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
374
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
374
375
  if (!apiKey) return null;
375
376
  const headers = { Accept: "application/json", Authorization: `Bearer ${apiKey}` } as const;
376
377
  const [subscriptionResponse, tokenResponse] = await Promise.all([
@@ -464,7 +465,7 @@ function parseOpenCodeGoUsageWindow(value: unknown): { percent: number; resetAt?
464
465
  async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
465
466
  // Never send a configured API key when the provider destination is not the built-in Go endpoint.
466
467
  if (!isCanonicalOpenCodeGoBaseUrl(config.baseUrl)) return null;
467
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
468
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
468
469
  if (!apiKey) return null;
469
470
  const response = await fetch(OPENCODE_GO_USAGE_URL, {
470
471
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -510,7 +511,7 @@ async function fetchOpenCodeGoQuota(provider: string, config: OcxProviderConfig)
510
511
  async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
511
512
  // Never send a configured API key to a lookalike host or through a redirect.
512
513
  if (!isCanonicalOpenRouterBaseUrl(config.baseUrl)) return null;
513
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
514
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
514
515
  if (!apiKey) return null;
515
516
  const response = await fetch(`${OPENROUTER_BASE_URL}/key`, {
516
517
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -557,7 +558,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig)
557
558
  */
558
559
  async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
559
560
  if (!isCanonicalDeepSeekBaseUrl(config.baseUrl)) return null;
560
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
561
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
561
562
  if (!apiKey) return null;
562
563
  const response = await fetch(`${DEEPSEEK_BASE_URL}/user/balance`, {
563
564
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -602,7 +603,7 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig):
602
603
  */
603
604
  async function fetchClineQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
604
605
  if (!isCanonicalClineBaseUrl(config.baseUrl)) return null;
605
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
606
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
606
607
  if (!apiKey) return null;
607
608
  const response = await fetch(`${CLINE_BASE_URL}/api/v1/users/me/plan/usage-limits`, {
608
609
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -750,7 +751,7 @@ function parseZaiQuotaLegacyFields(data: Record<string, unknown> | null): Provid
750
751
  */
751
752
  async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
752
753
  if (!isCanonicalZaiBaseUrl(config.baseUrl)) return null;
753
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
754
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
754
755
  if (!apiKey) return null;
755
756
  const normalized = normalizedBaseUrl(config.baseUrl);
756
757
  const monitorHost = normalized === ZAI_BASE_URL || normalized === `${ZAI_BASE_URL}/api/coding/paas/v4`
@@ -793,7 +794,7 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi
793
794
  */
794
795
  async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
795
796
  if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null;
796
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
797
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
797
798
  if (!apiKey) return null;
798
799
  const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com");
799
800
  const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL;
@@ -837,7 +838,7 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P
837
838
  */
838
839
  async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
839
840
  if (!isCanonicalMoonshotBaseUrl(config.baseUrl)) return null;
840
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
841
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
841
842
  if (!apiKey) return null;
842
843
  const host = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.moonshot.cn") ? "https://api.moonshot.cn/v1" : MOONSHOT_BASE_URL;
843
844
  const response = await fetch(`${host}/users/me/balance`, {
@@ -881,7 +882,7 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig):
881
882
  */
882
883
  async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
883
884
  if (!isCanonicalVeniceBaseUrl(config.baseUrl)) return null;
884
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
885
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
885
886
  if (!apiKey) return null;
886
887
  const response = await fetch(`${VENICE_BASE_URL}/billing/balance`, {
887
888
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -924,7 +925,7 @@ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Pr
924
925
  */
925
926
  async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
926
927
  if (!isCanonicalSyntheticBaseUrl(config.baseUrl)) return null;
927
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
928
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
928
929
  if (!apiKey) return null;
929
930
  const response = await fetch(`${SYNTHETIC_BASE_URL}/quotas`, {
930
931
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -972,7 +973,7 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig):
972
973
  */
973
974
  async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
974
975
  if (!isCanonicalDeepInfraBaseUrl(config.baseUrl)) return null;
975
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
976
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
976
977
  if (!apiKey) return null;
977
978
  const response = await fetch(`${DEEPINFRA_BASE_URL}/payment/checklist?compute_owed=true`, {
978
979
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -1014,7 +1015,7 @@ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig):
1014
1015
  */
1015
1016
  async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
1016
1017
  if (!isCanonicalNeuralwattBaseUrl(config.baseUrl)) return null;
1017
- const apiKey = resolveEnvValue(config.apiKey)?.trim();
1018
+ const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
1018
1019
  if (!apiKey) return null;
1019
1020
  const response = await fetch(`${NEURALWATT_BASE_URL}/quota`, {
1020
1021
  headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
@@ -1474,7 +1475,7 @@ export interface ProviderAccountQuota {
1474
1475
 
1475
1476
  /** Providers whose per-account quota can be probed. Extend as other OAuth APIs are covered. */
1476
1477
  export function supportsPerAccountQuota(provider: string): boolean {
1477
- return provider === "anthropic" || provider === "kiro";
1478
+ return provider === "anthropic" || provider === "kiro" || provider === "google-antigravity";
1478
1479
  }
1479
1480
 
1480
1481
  function accountCacheKey(provider: string, accountId: string): string {
@@ -1617,7 +1618,16 @@ async function fetchAccountQuota(
1617
1618
  quota = kiroSnapshot?.quota ?? null;
1618
1619
  } else {
1619
1620
  const token = await getTokenForAccountQuotaProbe(provider, accountId);
1620
- quota = await fetchAnthropicUsageQuota(token);
1621
+ if (provider === "google-antigravity") {
1622
+ // Per-account Gem/Cla windows (#1082). The project id is part of the stored
1623
+ // credential; without it the probe cannot be made, and that is "unavailable",
1624
+ // never 0%.
1625
+ const projectId = getAccountCredential(provider, accountId)?.projectId;
1626
+ if (!projectId) throw new Error("antigravity account has no project id");
1627
+ quota = await fetchAntigravityUsageQuota(token, projectId);
1628
+ } else {
1629
+ quota = await fetchAnthropicUsageQuota(token);
1630
+ }
1621
1631
  }
1622
1632
  if (!quota) {
1623
1633
  // Preserve last-good bars and mark unavailable; advance TTL so failures
@@ -1810,7 +1820,7 @@ async function resolveKimiQuotaBearer(config: OcxProviderConfig): Promise<string
1810
1820
  // ACTIVE key only: silently walking apiKeyPool when the primary env reference is
1811
1821
  // unresolved would render a quota bar for a DIFFERENT account than the one routing
1812
1822
  // requests — a wrong meter is worse than no meter.
1813
- const primary = resolveEnvValue(config.apiKey)?.trim();
1823
+ const primary = resolveProviderApiKey(config.apiKey)?.trim();
1814
1824
  return primary || null;
1815
1825
  }
1816
1826
 
@@ -1917,7 +1927,7 @@ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise
1917
1927
  }
1918
1928
  // ACTIVE key only: a quota bar for a different account than the one routing
1919
1929
  // requests is a wrong meter, not a helpful one.
1920
- return resolveEnvValue(config.apiKey)?.trim() || null;
1930
+ return resolveProviderApiKey(config.apiKey)?.trim() || null;
1921
1931
  }
1922
1932
 
1923
1933
  /**
@@ -2179,31 +2189,10 @@ function antigravityUsedPercent(quotaInfo: Record<string, unknown>): number | un
2179
2189
  return normalizePercent(100 - remaining);
2180
2190
  }
2181
2191
 
2182
- async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
2183
- const credential = getCredential("google-antigravity");
2184
- if (!credential?.projectId) return null;
2185
- let accessToken: string;
2186
- try {
2187
- accessToken = await getValidAccessToken("google-antigravity");
2188
- } catch {
2189
- return null;
2190
- }
2191
- const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, "");
2192
- const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
2193
- method: "POST",
2194
- headers: {
2195
- Accept: "application/json",
2196
- "Content-Type": "application/json",
2197
- "User-Agent": antigravityUserAgent(),
2198
- Authorization: `Bearer ${accessToken}`,
2199
- },
2200
- body: JSON.stringify({ project: credential.projectId }),
2201
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2202
- });
2203
- if (!response.ok) return null;
2204
- const body = asRecord(await readQuotaJson(response));
2192
+ /** Gem/Cla windows from a `fetchAvailableModels` body; shared by the provider and account probes. */
2193
+ function antigravityWindowsFromModels(body: Record<string, unknown> | null): ProviderQuotaWindow[] {
2205
2194
  const models = asRecord(body?.models);
2206
- if (!models) return null;
2195
+ if (!models) return [];
2207
2196
 
2208
2197
  const windows = new Map<string, ProviderQuotaWindow>();
2209
2198
  for (const [modelId, rawModelInfo] of Object.entries(models)) {
@@ -2226,6 +2215,66 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
2226
2215
  const window = windows.get(label);
2227
2216
  return window ? [window] : [];
2228
2217
  });
2218
+ return customWindows;
2219
+ }
2220
+
2221
+ const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com";
2222
+ let antigravityOutboundDependencies: ProviderOutboundDependencies = {};
2223
+
2224
+ /** Test seam: inject resolver/pinned transport for the per-account Antigravity probe. */
2225
+ export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void {
2226
+ antigravityOutboundDependencies = dependencies ?? {};
2227
+ }
2228
+
2229
+ /**
2230
+ * Per-account Antigravity quota (#1082). Always probes Google's own Cloud Code Assist host
2231
+ * through the pinned provider-outbound transport: a configured `baseUrl` is a routing choice
2232
+ * for requests, not a second source of Google's accounting for a stored credential, and fixing
2233
+ * the destination keeps the `provider\0accountId` cache identity exact across config changes.
2234
+ * A redirect or non-2xx yields null (unavailable), never a partial row.
2235
+ */
2236
+ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise<ProviderQuota | null> {
2237
+ const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`;
2238
+ const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
2239
+ headers: {
2240
+ Accept: "application/json",
2241
+ "Content-Type": "application/json",
2242
+ "User-Agent": antigravityUserAgent(),
2243
+ Authorization: `Bearer ${accessToken}`,
2244
+ },
2245
+ body: JSON.stringify({ project: projectId }),
2246
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2247
+ }, antigravityOutboundDependencies);
2248
+ if (await providerRedirectError(response, url)) return null;
2249
+ if (!response.ok) return null;
2250
+ const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
2251
+ if (customWindows.length === 0) return null;
2252
+ return { customWindows, updatedAt: Date.now() };
2253
+ }
2254
+
2255
+ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaReport | null> {
2256
+ const credential = getCredential("google-antigravity");
2257
+ if (!credential?.projectId) return null;
2258
+ let accessToken: string;
2259
+ try {
2260
+ accessToken = await getValidAccessToken("google-antigravity");
2261
+ } catch {
2262
+ return null;
2263
+ }
2264
+ const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, "");
2265
+ const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, {
2266
+ method: "POST",
2267
+ headers: {
2268
+ Accept: "application/json",
2269
+ "Content-Type": "application/json",
2270
+ "User-Agent": antigravityUserAgent(),
2271
+ Authorization: `Bearer ${accessToken}`,
2272
+ },
2273
+ body: JSON.stringify({ project: credential.projectId }),
2274
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
2275
+ });
2276
+ if (!response.ok) return null;
2277
+ const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
2229
2278
  if (customWindows.length === 0) return null;
2230
2279
  return report(provider, "google-antigravity:fetchAvailableModels", {
2231
2280
  customWindows,
@@ -13,10 +13,12 @@ import {
13
13
  CURSOR_NO_VISION_MODELS,
14
14
  CURSOR_STATIC_MODELS,
15
15
  cursorModelContextWindows,
16
+ cursorModelDisplayNames,
16
17
  cursorModelIds,
17
18
  cursorModelInputModalities,
18
19
  cursorModelReasoningEfforts,
19
20
  } from "../adapters/cursor/discovery";
21
+ import { cursorFastCapableBases } from "../adapters/cursor/catalog";
20
22
  import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts";
21
23
  import { isCanonicalOpenRouterTarget } from "./openrouter-routing";
22
24
 
@@ -270,6 +272,12 @@ export interface ProviderRegistryEntry {
270
272
  modelDiscovery?: ProviderModelDiscoverySpec;
271
273
  contextWindow?: number;
272
274
  modelContextWindows?: Record<string, number>;
275
+ /**
276
+ * Registry-supplied picker labels. Without these a routed row shows its raw slug,
277
+ * because `routedDisplayName` (codex/catalog/sync.ts) passes the slug through for every
278
+ * provider. An operator's `modelDisplayNames` still wins: derive only fills when absent.
279
+ */
280
+ modelDisplayNames?: Record<string, string>;
273
281
  modelInputModalities?: Record<string, string[]>;
274
282
  defaultMaxOutputTokens?: number;
275
283
  modelMaxOutputTokens?: Record<string, number>;
@@ -306,6 +314,7 @@ export interface ProviderRegistryEntry {
306
314
  preserveReasoningContentModels?: string[];
307
315
  requiresReasoningPlaceholderModels?: string[];
308
316
  reasoningSplitModels?: string[];
317
+ reasoningDetailsModels?: string[];
309
318
  thinkingToggleModels?: string[];
310
319
  thinkingBudgetModels?: string[];
311
320
  escapeBuiltinToolNames?: boolean;
@@ -324,10 +333,11 @@ export type ProviderConfigSeed = Pick<
324
333
  OcxProviderConfig,
325
334
  "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models"
326
335
  | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities"
336
+ | "modelDisplayNames"
327
337
  | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
328
338
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat"
329
339
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
330
- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance"
340
+ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance"
331
341
  | "googleMode" | "project" | "location" | "headers"
332
342
  >;
333
343
 
@@ -335,8 +345,10 @@ export type ProviderConfigSeed = Pick<
335
345
  // same static model seed.
336
346
  // 260710 context refresh: Tier-2 evidence in
337
347
  // devlog/_plan/260710_provider_hardening/001_research_frontier.md.
338
- const ANTHROPIC_MODELS = ["claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
339
- const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
348
+ // 260902 Claude Fable 5.1 (`claude-fable-5-1`): 1M context / 128K output / adaptive thinking
349
+ // always on, per the official models overview and pricing page (platform.claude.com).
350
+ const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
351
+ const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
340
352
 
341
353
  // 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's
342
354
  // devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and
@@ -1108,6 +1120,16 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1108
1120
  liveModels: true,
1109
1121
  defaultModel: "auto",
1110
1122
  modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS),
1123
+ modelDisplayNames: cursorModelDisplayNames(),
1124
+ // Cursor's Fast product is a model VARIANT, not a service_tier field, so the wire kind
1125
+ // is cursor-variant and the request builder consumes the decision.
1126
+ fastWire: { kind: "cursor-variant", canonicalToWire: { priority: "fast" }, foreignCallerTiers: "drop" },
1127
+ // Deliberately NO provider-level supportsServiceTier: resolveFastPolicy short-circuits on
1128
+ // `capability.provider === false` BEFORE consulting the per-model map, which would make
1129
+ // these entries dead config. Absent leaves unlisted bases "unclassified", and a
1130
+ // non-service-tier adapter cannot forward a caller tier, so they still publish no toggle.
1131
+ modelSupportsServiceTier: Object.fromEntries(cursorFastCapableBases().map(id => [id, true])),
1132
+ fastTierDescription: "Cursor Fast variant",
1111
1133
  modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS),
1112
1134
  modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS),
1113
1135
  // Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium`
@@ -2642,6 +2664,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2642
2664
  // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205).
2643
2665
  requiresReasoningPlaceholderModels: [],
2644
2666
  reasoningSplitModels: MINIMAX_MODELS,
2667
+ // With reasoning_split the upstream returns thinking as a structured
2668
+ // reasoning_details array (cumulative text snapshots per stream chunk) and
2669
+ // requires that array back verbatim on the next turn — a reasoning_content
2670
+ // string replay is the native-format pass-back the docs say is unsupported.
2671
+ // Evidence: platform.minimax.io/docs/guides/text-m3-function-call and
2672
+ // /docs/api-reference/text-openai-api (verified 2026-09-01).
2673
+ reasoningDetailsModels: MINIMAX_MODELS,
2645
2674
  thinkingToggleModels: ["MiniMax-M3"],
2646
2675
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key",
2647
2676
  },
@@ -2655,6 +2684,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2655
2684
  preserveReasoningContentModels: MINIMAX_MODELS,
2656
2685
  requiresReasoningPlaceholderModels: [],
2657
2686
  reasoningSplitModels: MINIMAX_MODELS,
2687
+ reasoningDetailsModels: MINIMAX_MODELS,
2658
2688
  thinkingToggleModels: ["MiniMax-M3"],
2659
2689
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key",
2660
2690
  },
@@ -80,6 +80,38 @@ export function codexEffortRank(effort: string): number {
80
80
  return CODEX_REASONING_ORDER.indexOf(effort);
81
81
  }
82
82
 
83
+ /**
84
+ * Resolve a requested effort against the rungs a target actually supports, never
85
+ * raising above the request.
86
+ *
87
+ * Returns the request itself when supported, otherwise the highest supported rung
88
+ * at or below it, otherwise the lowest supported rung, and `undefined` when the
89
+ * supported set contains no rankable rung at all (including the empty ladder, which
90
+ * is how a no-reasoning model is expressed).
91
+ *
92
+ * This lives here rather than beside its first caller because two very different
93
+ * planes need the same answer: the catalog advertises a combo's default effort, and
94
+ * the request path injects one. When they disagreed, the catalog promised `max` and
95
+ * the runtime silently sent nothing, so the provider default applied instead (#3108).
96
+ * `reasoning-effort.ts` is a leaf — its only import is `./types` — so the request
97
+ * path can share this without pulling the catalog plane along.
98
+ */
99
+ export function resolveEffortAtOrBelow(
100
+ requested: string | null | undefined,
101
+ supported: readonly string[],
102
+ ): string | undefined {
103
+ if (!requested) return undefined;
104
+ if (supported.includes(requested)) return requested;
105
+ const requestedRank = codexEffortRank(requested);
106
+ const ranked = supported
107
+ .map(effort => ({ effort, rank: codexEffortRank(effort) }))
108
+ .filter(item => item.rank >= 0)
109
+ .sort((a, b) => a.rank - b.rank);
110
+ if (ranked.length === 0) return undefined;
111
+ const atOrBelow = ranked.filter(item => item.rank <= requestedRank);
112
+ return atOrBelow.at(-1)?.effort ?? ranked[0]!.effort;
113
+ }
114
+
83
115
  export function modelRecordValue<T>(record: Record<string, T> | undefined, modelId: string): T | undefined {
84
116
  if (!record) return undefined;
85
117
  if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId];