@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -6,6 +6,7 @@ import {
6
6
  } from "../codex/auth-api";
7
7
  import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache";
8
8
  import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
9
+ import { codexPlanKey } from "../codex/plan";
9
10
  import { resolveEnvValue } from "../config";
10
11
  import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
11
12
  import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
@@ -20,6 +21,7 @@ import {
20
21
  sweepExpiredOnWrite,
21
22
  type GenerationContext,
22
23
  } from "../lib/state-store-sweeper";
24
+ import { readBoundedResponseBody } from "../lib/bounded-body";
23
25
  import {
24
26
  aggregateCodexPoolCapacity,
25
27
  CODEX_CAPACITY_MAX_QUOTA_AGE_MS,
@@ -32,6 +34,8 @@ const ACCOUNT_TOKEN_SKEW_MS = 60_000;
32
34
 
33
35
  const CACHE_TTL_MS = 5 * 60_000;
34
36
  const REQUEST_TIMEOUT_MS = 8_000;
37
+ /** Successful provider quota payloads are small; reject oversized or stalled JSON before parsing. */
38
+ export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
35
39
  const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
36
40
  const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
37
41
  const A6API_BASE_URL = "https://api.a6api.com";
@@ -168,7 +172,7 @@ function cacheKeyWithAggregationState(
168
172
  const rows = snapshot.accounts.map(account => ({
169
173
  isMain: account.isMain,
170
174
  active: account.id === activeId,
171
- plan: account.plan?.trim().toLowerCase() ?? null,
175
+ plan: codexPlanKey(account.plan) ?? null,
172
176
  paused: account.paused,
173
177
  needsReauth: account.needsReauth === true,
174
178
  quota: quotaSignatureValue(account.quota as CodexCapacityQuota | null),
@@ -259,6 +263,43 @@ function asRecord(value: unknown): Record<string, unknown> | null {
259
263
  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
260
264
  }
261
265
 
266
+ const QUOTA_JSON_READ_FAILURE = Symbol("quota-json-read-failure");
267
+
268
+ async function readQuotaJson(
269
+ response: Response,
270
+ timeoutMs = REQUEST_TIMEOUT_MS,
271
+ ): Promise<unknown | typeof QUOTA_JSON_READ_FAILURE> {
272
+ const declaredLength = Number(response.headers.get("content-length"));
273
+ if (Number.isFinite(declaredLength) && declaredLength > QUOTA_RESPONSE_MAX_BYTES) {
274
+ try {
275
+ void response.body?.cancel(
276
+ new DOMException("Provider quota response is too large", "QuotaExceededError"),
277
+ ).catch(() => undefined);
278
+ } catch {
279
+ // Best-effort cancellation only.
280
+ }
281
+ return QUOTA_JSON_READ_FAILURE;
282
+ }
283
+
284
+ try {
285
+ const bounded = await readBoundedResponseBody(response, {
286
+ maxBytes: QUOTA_RESPONSE_MAX_BYTES,
287
+ totalTimeoutMs: timeoutMs,
288
+ inactivityTimeoutMs: timeoutMs,
289
+ });
290
+ if (bounded.oversized || bounded.truncated || !bounded.displaySafe) return QUOTA_JSON_READ_FAILURE;
291
+ return JSON.parse(bounded.text) as unknown;
292
+ } catch {
293
+ return QUOTA_JSON_READ_FAILURE;
294
+ }
295
+ }
296
+
297
+ /** Test-only access to the quota reader's deadline and cancellation contract. */
298
+ export async function readProviderQuotaJsonForTests(response: Response, timeoutMs: number): Promise<unknown> {
299
+ const result = await readQuotaJson(response, timeoutMs);
300
+ return result === QUOTA_JSON_READ_FAILURE ? null : result;
301
+ }
302
+
262
303
  function isBuiltInChatGptForwardProvider(name: string, provider: OcxProviderConfig): boolean {
263
304
  return name === OPENAI_CODEX_PROVIDER_ID && isCanonicalOpenAiForwardProvider(provider);
264
305
  }
@@ -353,8 +394,13 @@ async function fetchA6apiQuota(provider: string, config: OcxProviderConfig): Pro
353
394
  ? TERMINAL_QUOTA_FAILURE
354
395
  : null;
355
396
  }
356
- const subscription = a6apiPayload(await subscriptionResponse.json().catch(() => null));
357
- const token = a6apiPayload(await tokenResponse.json().catch(() => null));
397
+ const [subscriptionBody, tokenBody] = await Promise.all([
398
+ readQuotaJson(subscriptionResponse),
399
+ readQuotaJson(tokenResponse),
400
+ ]);
401
+ if (subscriptionBody === QUOTA_JSON_READ_FAILURE || tokenBody === QUOTA_JSON_READ_FAILURE) return null;
402
+ const subscription = a6apiPayload(subscriptionBody);
403
+ const token = a6apiPayload(tokenBody);
358
404
  const unlimited = token?.unlimited_quota === true
359
405
  || token?.unlimited_quota === 1
360
406
  || token?.unlimited_quota === "true";
@@ -432,7 +478,7 @@ async function fetchOpenRouterQuota(provider: string, config: OcxProviderConfig)
432
478
  ? TERMINAL_QUOTA_FAILURE
433
479
  : null;
434
480
  }
435
- const body = asRecord(await response.json().catch(() => null));
481
+ const body = asRecord(await readQuotaJson(response));
436
482
  const data = asRecord(body?.data) ?? body;
437
483
  if (!data) return null;
438
484
  const limit = toFiniteNumber(data.limit);
@@ -479,7 +525,7 @@ async function fetchDeepSeekQuota(provider: string, config: OcxProviderConfig):
479
525
  ? TERMINAL_QUOTA_FAILURE
480
526
  : null;
481
527
  }
482
- const body = asRecord(await response.json().catch(() => null));
528
+ const body = asRecord(await readQuotaJson(response));
483
529
  // The payload nests balances under `balance_infos` rows keyed by currency;
484
530
  // prefer a USD row, then CNY, then the first row that parses.
485
531
  const infos = Array.isArray(body?.balance_infos) ? body.balance_infos as unknown[] : null;
@@ -527,7 +573,7 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
527
573
  ? TERMINAL_QUOTA_FAILURE
528
574
  : null;
529
575
  }
530
- const body = asRecord(await response.json().catch(() => null));
576
+ const body = asRecord(await readQuotaJson(response));
531
577
  const data = asRecord(body?.data) ?? body;
532
578
  const limits = Array.isArray(data?.limits) ? data.limits : null;
533
579
  if (!limits) return null;
@@ -575,7 +621,7 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi
575
621
  ? TERMINAL_QUOTA_FAILURE
576
622
  : null;
577
623
  }
578
- const body = asRecord(await response.json().catch(() => null));
624
+ const body = asRecord(await readQuotaJson(response));
579
625
  if (!body || body.success === false) return null;
580
626
  const data = asRecord(body.data) ?? body;
581
627
  // The plugin renders a 5h token window, a weekly window, and a monthly MCP
@@ -631,7 +677,7 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P
631
677
  ? TERMINAL_QUOTA_FAILURE
632
678
  : null;
633
679
  }
634
- const body = asRecord(await response.json().catch(() => null));
680
+ const body = asRecord(await readQuotaJson(response));
635
681
  if (!body || body.success === false) return null;
636
682
  const data = asRecord(body.data) ?? body;
637
683
  const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime);
@@ -674,7 +720,7 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig):
674
720
  ? TERMINAL_QUOTA_FAILURE
675
721
  : null;
676
722
  }
677
- const body = asRecord(await response.json().catch(() => null));
723
+ const body = asRecord(await readQuotaJson(response));
678
724
  const data = asRecord(body?.data) ?? body;
679
725
  if (!data) return null;
680
726
  const available = toFiniteNumber(data.available_balance);
@@ -710,7 +756,7 @@ async function fetchVeniceQuota(provider: string, config: OcxProviderConfig): Pr
710
756
  ? TERMINAL_QUOTA_FAILURE
711
757
  : null;
712
758
  }
713
- const body = asRecord(await response.json().catch(() => null));
759
+ const body = asRecord(await readQuotaJson(response));
714
760
  const data = asRecord(body?.data) ?? body;
715
761
  if (!data) return null;
716
762
  const diemBalance = toFiniteNumber(data.balance);
@@ -753,7 +799,7 @@ async function fetchSyntheticQuota(provider: string, config: OcxProviderConfig):
753
799
  ? TERMINAL_QUOTA_FAILURE
754
800
  : null;
755
801
  }
756
- const body = asRecord(await response.json().catch(() => null));
802
+ const body = asRecord(await readQuotaJson(response));
757
803
  const data = asRecord(body?.data) ?? body;
758
804
  const quota: ProviderQuota = { updatedAt: Date.now() };
759
805
  let windows = 0;
@@ -801,7 +847,7 @@ async function fetchDeepInfraQuota(provider: string, config: OcxProviderConfig):
801
847
  ? TERMINAL_QUOTA_FAILURE
802
848
  : null;
803
849
  }
804
- const body = asRecord(await response.json().catch(() => null));
850
+ const body = asRecord(await readQuotaJson(response));
805
851
  const data = asRecord(body?.data) ?? body;
806
852
  if (!data) return null;
807
853
  const stripeBalance = toFiniteNumber(data.stripe_balance);
@@ -843,7 +889,7 @@ async function fetchNeuralwattQuota(provider: string, config: OcxProviderConfig)
843
889
  ? TERMINAL_QUOTA_FAILURE
844
890
  : null;
845
891
  }
846
- const body = asRecord(await response.json().catch(() => null));
892
+ const body = asRecord(await readQuotaJson(response));
847
893
  const data = asRecord(body?.data) ?? body;
848
894
  const quota: ProviderQuota = { updatedAt: Date.now() };
849
895
  let windows = 0;
@@ -1027,7 +1073,7 @@ async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promi
1027
1073
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1028
1074
  });
1029
1075
  if (!response.ok) return null;
1030
- const parsed = parseXaiCreditsResponse(await response.json().catch(() => null));
1076
+ const parsed = parseXaiCreditsResponse(await readQuotaJson(response));
1031
1077
  if (!parsed) return null;
1032
1078
  return {
1033
1079
  weeklyPercent: parsed.percent,
@@ -1061,7 +1107,7 @@ async function fetchXaiQuota(provider: string): Promise<ProviderQuotaReport | nu
1061
1107
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1062
1108
  });
1063
1109
  if (!response.ok) return null;
1064
- const body = asRecord(await response.json().catch(() => null));
1110
+ const body = asRecord(await readQuotaJson(response));
1065
1111
  const config = asRecord(body?.config);
1066
1112
  if (!config) return null;
1067
1113
  const limitCents = centsValue(config.monthlyLimit);
@@ -1107,7 +1153,7 @@ async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQu
1107
1153
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1108
1154
  });
1109
1155
  if (!response.ok) return null;
1110
- const body = asRecord(await response.json().catch(() => null));
1156
+ const body = asRecord(await readQuotaJson(response));
1111
1157
  if (!body) return null;
1112
1158
  const fiveHour = parseClaudeBucket(body.five_hour);
1113
1159
  const sevenDay = parseClaudeBucket(body.seven_day);
@@ -1520,7 +1566,7 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
1520
1566
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1521
1567
  });
1522
1568
  if (!response.ok) return null;
1523
- const quota = parseKimiQuotaPayload(await response.json().catch(() => null));
1569
+ const quota = parseKimiQuotaPayload(await readQuotaJson(response));
1524
1570
  return quota ? report(provider, "kimi:usages", quota) : null;
1525
1571
  }
1526
1572
 
@@ -1553,7 +1599,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
1553
1599
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1554
1600
  });
1555
1601
  if (periodRes.ok) {
1556
- const body = asRecord(await periodRes.json().catch(() => null));
1602
+ const body = asRecord(await readQuotaJson(periodRes));
1557
1603
  const planUsage = asRecord(body?.planUsage);
1558
1604
  if (planUsage) {
1559
1605
  const resetAt = normalizeResetAt(body?.billingCycleEnd ?? planUsage.billingCycleEnd ?? body?.periodEnd);
@@ -1615,7 +1661,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
1615
1661
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1616
1662
  });
1617
1663
  if (summaryRes.ok) {
1618
- const body = asRecord(await summaryRes.json().catch(() => null));
1664
+ const body = asRecord(await readQuotaJson(summaryRes));
1619
1665
  const individual = asRecord(body?.individualUsage);
1620
1666
  const plan = asRecord(individual?.plan);
1621
1667
  if (plan) {
@@ -1644,7 +1690,7 @@ async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport |
1644
1690
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1645
1691
  });
1646
1692
  if (!response.ok) return null;
1647
- const body = asRecord(await response.json().catch(() => null));
1693
+ const body = asRecord(await readQuotaJson(response));
1648
1694
  if (!body) return null;
1649
1695
 
1650
1696
  // Prefer the gpt-4 bucket (historical "fast requests"); else first model with used+limit.
@@ -1758,7 +1804,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig
1758
1804
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1759
1805
  });
1760
1806
  if (!response.ok) return null;
1761
- const body = asRecord(await response.json().catch(() => null));
1807
+ const body = asRecord(await readQuotaJson(response));
1762
1808
  const models = asRecord(body?.models);
1763
1809
  if (!models) return null;
1764
1810
 
@@ -186,6 +186,11 @@ export interface ProviderRegistryEntry {
186
186
  * replay miss are repaired rather than forwarded.
187
187
  */
188
188
  statelessResponses?: boolean;
189
+ /**
190
+ * Responses parser requires a matched tool result directly after its call. This is
191
+ * seeded/backfilled like other fixed upstream wire-contract capabilities.
192
+ */
193
+ requiresAdjacentResponsesToolResults?: boolean;
189
194
  /**
190
195
  * Registry default for the provider's Responses `service_tier` support; see
191
196
  * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never
@@ -209,6 +214,12 @@ export interface ProviderRegistryEntry {
209
214
  modelDefaultReasoningEfforts?: Record<string, string>;
210
215
  reasoningEffortMap?: Record<string, string>;
211
216
  modelReasoningEffortMap?: Record<string, Record<string, string>>;
217
+ /**
218
+ * Registry-authoritative models that send OpenAI's direct `reasoning_effort` field.
219
+ * Runtime enrichment uses this to repair stale preset metadata that still classifies a model
220
+ * as a thinking-budget/toggle model. This is registry-only and is never persisted as user config.
221
+ */
222
+ directReasoningEffortModels?: string[];
212
223
  reasoningWireFormat?: OcxProviderConfig["reasoningWireFormat"];
213
224
  noVisionModels?: string[];
214
225
  noReasoningModels?: string[];
@@ -221,6 +232,7 @@ export interface ProviderRegistryEntry {
221
232
  promptCacheKey?: boolean;
222
233
  autoToolChoiceOnlyModels?: string[];
223
234
  preserveReasoningContentModels?: string[];
235
+ requiresReasoningPlaceholderModels?: string[];
224
236
  reasoningSplitModels?: string[];
225
237
  thinkingToggleModels?: string[];
226
238
  thinkingBudgetModels?: string[];
@@ -243,7 +255,7 @@ export type ProviderConfigSeed = Pick<
243
255
  | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
244
256
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat"
245
257
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
246
- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
258
+ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
247
259
  | "googleMode" | "project" | "location" | "headers"
248
260
  >;
249
261
 
@@ -344,6 +356,9 @@ const ZHIPU_BIGMODEL_INPUT_MODALITIES: Record<string, string[]> = {
344
356
  };
345
357
  const ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS = ["glm-4.6", "glm-4.7", "glm-5", "glm-5.1"];
346
358
  const THINKING_BUDGET_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
359
+ // Qwen3.8-Max is the first Qwen3.x model with official direct `reasoning_effort` support.
360
+ // Evidence: https://qwen.ai/blog?id=qwen3.8
361
+ const QWEN38_REASONING_EFFORTS = ["low", "medium", "xhigh"];
347
362
  const THINKING_BUDGET_MODELS = [
348
363
  "qwen3.5-397b", "qwen3.6-35b",
349
364
  "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus",
@@ -1359,6 +1374,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1359
1374
  // "The API is stateless: responses and conversations are not stored on the
1360
1375
  // server." https://api-docs.deepseek.com/api/create-response/
1361
1376
  statelessResponses: true,
1377
+ // DeepSeek rejects a valid Codex continuation when hook-provided developer
1378
+ // context is persisted between a call and its matching result (#1292).
1379
+ requiresAdjacentResponsesToolResults: true,
1362
1380
  /* [Decision Log]
1363
1381
  - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content.
1364
1382
  - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata.
@@ -1375,6 +1393,40 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1375
1393
  },
1376
1394
  // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
1377
1395
  { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" },
1396
+ {
1397
+ // Primary sources checked 2026-08-08:
1398
+ // - https://chutes.ai/pricing documents the shared llm.chutes.ai/v1 OpenAI-compatible
1399
+ // gateway, Bearer API keys, and chat completions. Its public
1400
+ // https://llm.chutes.ai/v1/models response supplies supported_features for filtering.
1401
+ // - https://chutes.ai/terms identifies Chutes Global Corp as the platform operator, applies
1402
+ // to API consumers, and directs production/high-volume automated inference to PAYGO.
1403
+ // Maintainer: @olddonkey; no affiliation with Chutes.
1404
+ id: "chutes",
1405
+ label: "Chutes",
1406
+ baseUrl: "https://llm.chutes.ai/v1",
1407
+ adapter: "openai-chat",
1408
+ authKind: "key",
1409
+ dashboardUrl: "https://chutes.ai/auth/start",
1410
+ liveModels: true,
1411
+ preserveCustomDestination: true,
1412
+ // The public model catalog cannot prove that a supplied Bearer key is valid.
1413
+ apiKeyValidation: "unknown",
1414
+ // Chutes documents tool calling, but not a provider-wide parallel tool-call contract.
1415
+ parallelToolCalls: false,
1416
+ // The live catalog reports reasoning support, but not a stable effort ladder.
1417
+ reasoningEfforts: [],
1418
+ modelDiscovery: {
1419
+ path: "models",
1420
+ maxResponseBytes: 256 * 1024,
1421
+ maxModels: 128,
1422
+ filter: {
1423
+ // The shared LLM catalog also contains rows without native tool support. Codex needs a
1424
+ // complete agent loop, so admit only rows whose live metadata advertises tools.
1425
+ allOf: [{ path: ["supported_features"], containsAny: ["tools"] }],
1426
+ },
1427
+ },
1428
+ note: "Shared OpenAI-compatible LLM gateway only; live discovery exposes tool-capable rows. User-deployed custom Chute endpoints and non-LLM APIs require a custom provider.",
1429
+ },
1378
1430
  {
1379
1431
  id: "deepinfra",
1380
1432
  label: "DeepInfra",
@@ -1621,6 +1673,53 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1621
1673
  },
1622
1674
  note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.",
1623
1675
  },
1676
+ {
1677
+ // Primary sources checked 2026-08-08:
1678
+ // - https://featherless.ai/docs/api-overview-and-common-options documents the fixed
1679
+ // OpenAI-compatible base URL, Bearer keys, and Chat Completions.
1680
+ // - https://featherless.ai/docs/api-reference-models documents authenticated plan filtering,
1681
+ // chat capability filtering, popularity sorting, pagination, and per-row tool metadata.
1682
+ // - https://featherless.ai/legal/terms-of-service identifies Featherless as a Delaware LLC,
1683
+ // covers developers building on its APIs, and reserves arbitrary applications for Scale
1684
+ // plans. Maintainer: @olddonkey; no affiliation with Featherless.
1685
+ id: "featherless",
1686
+ label: "Featherless AI",
1687
+ baseUrl: "https://api.featherless.ai/v1",
1688
+ adapter: "openai-chat",
1689
+ authKind: "key",
1690
+ dashboardUrl: "https://featherless.ai/account/api-keys",
1691
+ liveModels: true,
1692
+ preserveCustomDestination: true,
1693
+ // /v1/models is documented as callable authenticated or unauthenticated, so a 2xx catalog
1694
+ // response cannot prove the supplied Bearer key is valid.
1695
+ apiKeyValidation: "unknown",
1696
+ // Featherless documents tool calling, but not a provider-wide parallel tool-call contract.
1697
+ parallelToolCalls: false,
1698
+ // Reasoning controls use model-specific chat_template_kwargs, not OpenAI reasoning_effort.
1699
+ reasoningEfforts: [],
1700
+ modelDiscovery: {
1701
+ path: "models",
1702
+ query: {
1703
+ available_on_current_plan: "true",
1704
+ capabilities: "chat",
1705
+ page: "1",
1706
+ per_page: "100",
1707
+ sort: "-popularity",
1708
+ },
1709
+ maxResponseBytes: 128 * 1024,
1710
+ maxModels: 100,
1711
+ filter: {
1712
+ // Treat server-side filters as a size optimization, not an authority boundary. A row must
1713
+ // independently prove plan availability, no separate Hugging Face gate, and tool support.
1714
+ allOf: [
1715
+ { path: ["available_on_current_plan"], equalsAny: [true] },
1716
+ { path: ["is_gated"], equalsAny: [false] },
1717
+ { path: ["features", "tool_use"], equalsAny: [true] },
1718
+ ],
1719
+ },
1720
+ },
1721
+ note: "Authenticated first page of popular chat models only; live discovery admits at most 100 plan-available, ungated rows whose metadata explicitly reports tool use.",
1722
+ },
1624
1723
  // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
1625
1724
  { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
1626
1725
  { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
@@ -1721,6 +1820,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1721
1820
  ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]),
1722
1821
  ),
1723
1822
  preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS,
1823
+ // GLM thinking is a binary toggle (low maps to disabled), so a legitimate
1824
+ // tool round can carry no reasoning at all; never fabricate a placeholder
1825
+ // for it, only replay real recorded text (P2 on #1205).
1826
+ requiresReasoningPlaceholderModels: [],
1724
1827
  // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a
1725
1828
  // false live claim yields an empty picker at runtime. Flip it on once someone verifies it.
1726
1829
  note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)",
@@ -1899,11 +2002,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1899
2002
  },
1900
2003
  modelReasoningEfforts: {
1901
2004
  ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
2005
+ "qwen3.8-max": QWEN38_REASONING_EFFORTS,
1902
2006
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
1903
2007
  "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"),
1904
2008
  },
2009
+ modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" },
1905
2010
  modelReasoningEffortMap: { "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro") },
1906
- thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS,
2011
+ directReasoningEffortModels: ["qwen3.8-max"],
2012
+ thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"),
1907
2013
  preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"],
1908
2014
  noVisionModels: ["glm-5.2", "deepseek-v4-pro"],
1909
2015
  },
@@ -1932,7 +2038,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1932
2038
  },
1933
2039
  modelReasoningEfforts: {
1934
2040
  ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
1935
- "qwen3.8-max": ["low", "high", "xhigh"],
2041
+ "qwen3.8-max": QWEN38_REASONING_EFFORTS,
1936
2042
  "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
1937
2043
  "deepseek-v4-pro": deepseekThinkingEffortsFor("deepseek-v4-pro"),
1938
2044
  "deepseek-v4-flash": deepseekThinkingEffortsFor("deepseek-v4-flash"),
@@ -1941,7 +2047,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1941
2047
  "deepseek-v4-pro": deepseekReasoningMapFor("deepseek-v4-pro"),
1942
2048
  "deepseek-v4-flash": deepseekReasoningMapFor("deepseek-v4-flash"),
1943
2049
  },
1944
- thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS,
2050
+ directReasoningEffortModels: ["qwen3.8-max"],
2051
+ thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"),
1945
2052
  preserveReasoningContentModels: ["glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"],
1946
2053
  noVisionModels: ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek-v3.2", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
1947
2054
  noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"],
@@ -1995,6 +2102,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1995
2102
  modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
1996
2103
  modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
1997
2104
  preserveReasoningContentModels: MINIMAX_MODELS,
2105
+ // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool
2106
+ // round can carry no reasoning at all; only replay real recorded text,
2107
+ // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205).
2108
+ requiresReasoningPlaceholderModels: [],
1998
2109
  reasoningSplitModels: MINIMAX_MODELS,
1999
2110
  thinkingToggleModels: ["MiniMax-M3"],
2000
2111
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key",
@@ -2007,6 +2118,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2007
2118
  modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" },
2008
2119
  modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP },
2009
2120
  preserveReasoningContentModels: MINIMAX_MODELS,
2121
+ requiresReasoningPlaceholderModels: [],
2010
2122
  reasoningSplitModels: MINIMAX_MODELS,
2011
2123
  thinkingToggleModels: ["MiniMax-M3"],
2012
2124
  jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key",
@@ -2037,6 +2149,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2037
2149
  // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek
2038
2150
  // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other
2039
2151
  // Zen DeepSeek thinking models — never serialize a bare tool-call turn.
2152
+ note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.",
2040
2153
  modelReasoningEfforts: Object.fromEntries(
2041
2154
  [...DEEPSEEK_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]),
2042
2155
  ),
@@ -2056,7 +2169,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2056
2169
  keyOptional: true,
2057
2170
  featured: true,
2058
2171
  liveModels: true,
2059
- note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
2172
+ note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
2060
2173
  dashboardUrl: "https://opencode.ai",
2061
2174
  staticHeaders: {
2062
2175
  "x-opencode-client": "desktop",
package/src/router.ts CHANGED
@@ -12,6 +12,7 @@ import { hasOwnProvider, resolveEnvValue } from "./config";
12
12
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
13
13
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
14
14
  import { PROVIDER_REGISTRY, providerCodexAccountMode, providerMatchesRegistryTransport } from "./providers/registry";
15
+ import { applyDirectReasoningEffortContracts } from "./providers/derive";
15
16
  import {
16
17
  isCanonicalOpenAiForwardProvider,
17
18
  LEGACY_CHATGPT_PROVIDER_ID,
@@ -284,6 +285,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
284
285
  const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
285
286
  const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
286
287
  const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
288
+ const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels);
287
289
  const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
288
290
  const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
289
291
  const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
@@ -300,13 +302,17 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
300
302
  if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl);
301
303
  assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
302
304
 
303
- return {
305
+ const resolved: OcxProviderConfig = {
304
306
  ...provider,
305
307
  adapter: registryEntry.adapter,
306
308
  baseUrl,
307
309
  ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined
308
310
  ? { responsesPath: registryEntry.responsesPath }
309
311
  : {}),
312
+ ...(provider.requiresAdjacentResponsesToolResults === undefined
313
+ && registryEntry.requiresAdjacentResponsesToolResults !== undefined
314
+ ? { requiresAdjacentResponsesToolResults: registryEntry.requiresAdjacentResponsesToolResults }
315
+ : {}),
310
316
  ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined
311
317
  ? { supportsServiceTier: registryEntry.supportsServiceTier }
312
318
  : {}),
@@ -368,10 +374,13 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
368
374
  ...(noPenaltyModels ? { noPenaltyModels } : {}),
369
375
  ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
370
376
  ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
377
+ ...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}),
371
378
  ...(reasoningSplitModels ? { reasoningSplitModels } : {}),
372
379
  ...(thinkingToggleModels ? { thinkingToggleModels } : {}),
373
380
  ...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
374
381
  };
382
+ applyDirectReasoningEffortContracts(registryEntry, resolved, provider);
383
+ return resolved;
375
384
  }
376
385
 
377
386
  function activeProviderEntries(config: OcxConfig): [string, OcxProviderConfig][] {
@@ -675,6 +684,11 @@ export function routeModel(
675
684
  return route;
676
685
  }
677
686
 
687
+ /** Resolve a combo-selected provider/model target without consulting public combo aliases again. */
688
+ export function routeConcreteModel(config: OcxConfig, modelId: string): RouteResult {
689
+ return routeModelInternal(config, modelId, true, undefined);
690
+ }
691
+
678
692
  function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
679
693
  for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
680
694
  if (prefixes.some(prefix => modelId.startsWith(prefix))) {
@@ -11,7 +11,7 @@
11
11
  * sample for the full history.
12
12
  */
13
13
 
14
- import type { PersistedUsageEntry, PersistedUsageAttempt } from "../usage/log";
14
+ import type { PersistedUsageEntry } from "../usage/log";
15
15
  import { estimateRequestCost, serviceTierContext } from "../usage/cost";
16
16
  import { openRequestHistoryIndex, requestHistoryDb } from "./history/indexer";
17
17
 
@@ -144,14 +144,15 @@ function parseEntry(rowJson: string): PersistedUsageEntry | null {
144
144
  }
145
145
  }
146
146
 
147
- function attemptsOf(entry: PersistedUsageEntry | null): PersistedUsageAttempt[] | undefined {
148
- return entry?.attempts;
149
- }
150
-
151
147
  function cooldownTriggering(entry: PersistedUsageEntry | null, status: number): boolean {
152
148
  if (status === 429) return true;
153
- const attempts = attemptsOf(entry) ?? [];
154
- return attempts.some(attempt => attempt.recoveryKinds.some(kind => COOLDOWN_RECOVERY_KINDS.has(kind)));
149
+ if (!Array.isArray(entry?.attempts)) return false;
150
+ return entry.attempts.some((attempt: unknown) => {
151
+ if (!attempt || typeof attempt !== "object") return false;
152
+ const recoveryKinds = (attempt as { recoveryKinds?: unknown }).recoveryKinds;
153
+ return Array.isArray(recoveryKinds)
154
+ && recoveryKinds.some(kind => typeof kind === "string" && COOLDOWN_RECOVERY_KINDS.has(kind));
155
+ });
155
156
  }
156
157
 
157
158
  function successCostUsd(
@@ -304,14 +304,58 @@ export function evaluatePolicyProfile(
304
304
  const excludedByUnknown = unknown && profile.unknownEvidence.capability === "exclude";
305
305
  const costLimit = profile.limits.maxEstimatedCostUsd;
306
306
  const estimatedCost = evidence.cost?.estimatedUsd;
307
+ const costEstimateKnown = typeof estimatedCost === "number" && Number.isFinite(estimatedCost);
307
308
  const overCostLimit = costLimit !== undefined
308
- && typeof estimatedCost === "number"
309
- && Number.isFinite(estimatedCost)
310
- && estimatedCost > costLimit;
309
+ && costEstimateKnown
310
+ && estimatedCost! > costLimit;
311
311
  if (overCostLimit) {
312
312
  exclusions.push({ code: "cost-limit", detail: "maxEstimatedCostUsd" });
313
313
  }
314
- let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit;
314
+ // A cap can only be *proven* satisfied when the estimate is known. The live
315
+ // routing path often has no usage evidence yet, so the default stays
316
+ // "allow" to preserve the documented dry-run contract; operators who need a
317
+ // genuine hard ceiling opt into "exclude". Exclusions cover only the
318
+ // fail-closed path; the allow path stamps `cost.capOutcome` so operators
319
+ // can still distinguish "known under the cap" from "unknown cost allowed".
320
+ const unknownCostUnderCap = costLimit !== undefined && !costEstimateKnown;
321
+ const unknownCostBlocked = unknownCostUnderCap
322
+ && profile.limits.onUnknownCost === "exclude";
323
+ if (unknownCostBlocked) {
324
+ exclusions.push({ code: "cost-limit-unknown", detail: "maxEstimatedCostUsd" });
325
+ }
326
+ let eligible = !unsatisfied && !excludedByUnknown && !overCostLimit && !unknownCostBlocked;
327
+
328
+ // Trace/dry-run copy only: report the profile cap that was applied and the
329
+ // operator-visible outcome. Do not feed this copy into costScore() — that
330
+ // would silently change ranking when a caller supplied a different
331
+ // limitUsd (costScore uses limitUsd as its reference denominator).
332
+ let costForCandidate = evidence.cost;
333
+ if (costLimit !== undefined) {
334
+ const capOutcome = overCostLimit
335
+ ? "exceeded" as const
336
+ : unknownCostBlocked
337
+ ? "unknown-excluded" as const
338
+ : unknownCostUnderCap
339
+ ? "unknown-allowed" as const
340
+ : "satisfied" as const;
341
+ if (!costEstimateKnown) {
342
+ // Missing or non-finite estimates are the same unknown: never stamp
343
+ // Infinity/NaN into the trace, and always mark incomplete.
344
+ const { estimatedUsd: _nonFiniteOrMissing, ...rest } = evidence.cost ?? {};
345
+ costForCandidate = {
346
+ ...rest,
347
+ incomplete: true,
348
+ limitUsd: costLimit,
349
+ capOutcome,
350
+ };
351
+ } else {
352
+ costForCandidate = {
353
+ ...evidence.cost!,
354
+ limitUsd: costLimit,
355
+ capOutcome,
356
+ };
357
+ }
358
+ }
315
359
 
316
360
  // Health scoring (RI-06): live hard cooldown is authoritative and
317
361
  // excludes; unknown health follows the profile's unknownEvidence policy;
@@ -347,6 +391,8 @@ export function evaluatePolicyProfile(
347
391
 
348
392
  // Cost scoring (RI-08): the hard per-request ceiling was already checked
349
393
  // above; unknown cost follows the profile's unknownEvidence policy.
394
+ // Score against the caller's original evidence so trace stamping cannot
395
+ // move the costScore reference / ranking.
350
396
  const cost = evidence.cost;
351
397
  let costValue = cost ? costScore(cost) : null;
352
398
  if (costValue === null && profile.unknownEvidence.cost === "exclude") {
@@ -394,7 +440,7 @@ export function evaluatePolicyProfile(
394
440
  ...(evidence.capability ? { capability: evidence.capability } : {}),
395
441
  ...(evidence.health ? { health: evidence.health } : {}),
396
442
  ...(evidence.quota ? { quota: evidence.quota } : {}),
397
- ...(evidence.cost ? { cost: evidence.cost } : {}),
443
+ ...(costForCandidate ? { cost: costForCandidate } : {}),
398
444
  score,
399
445
  };
400
446
  candidates.push(evaluated);