@bitkyc08/opencodex 2.81.0 → 2.83.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 (272) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ba8uG696.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-IImbNPRy.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-BHRZdJuv.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DiJO6sYY.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/cursor/catalog.ts +2 -2
  11. package/src/adapters/cursor/effort-map.ts +1 -1
  12. package/src/adapters/devin/live-models.ts +2 -2
  13. package/src/adapters/google-truncation.ts +22 -0
  14. package/src/adapters/google.ts +7 -3
  15. package/src/adapters/ollama-native.ts +18 -14
  16. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  17. package/src/adapters/openai-responses/passthrough.ts +11 -4
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  21. package/src/bridge/errors.ts +7 -1
  22. package/src/claude/agents-inject.ts +14 -3
  23. package/src/claude/context-windows.ts +22 -20
  24. package/src/claude/desktop-3p.ts +88 -12
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/desktop-profile.ts +2 -1
  27. package/src/claude/gateway-cache.ts +18 -1
  28. package/src/claude/inbound.ts +77 -3
  29. package/src/claude/intercept/cli-catalog.ts +3 -0
  30. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  31. package/src/claude/intercept/picker-models.ts +14 -6
  32. package/src/claude/intercept/settings.ts +3 -0
  33. package/src/claude/long-context.ts +66 -0
  34. package/src/claude/model-info.ts +19 -16
  35. package/src/claude/outbound.ts +94 -9
  36. package/src/claude/subagent-model.ts +40 -11
  37. package/src/cli/account-auth.ts +20 -12
  38. package/src/cli/agent.ts +9 -5
  39. package/src/cli/aside-profile-recovery.ts +33 -0
  40. package/src/cli/capabilities-base.ts +5 -5
  41. package/src/cli/capabilities-integrations.ts +2 -2
  42. package/src/cli/cli-path-diagnostics.ts +132 -0
  43. package/src/cli/desktop-runtime-guidance.ts +65 -0
  44. package/src/cli/dispatch.ts +5 -0
  45. package/src/cli/doctor.ts +16 -3
  46. package/src/cli/index.ts +14 -9
  47. package/src/cli/integration-aside-sync.ts +2 -1
  48. package/src/cli/integrations.ts +16 -3
  49. package/src/cli/resolve.ts +26 -3
  50. package/src/cli/root.ts +4 -2
  51. package/src/cli/status.ts +64 -12
  52. package/src/cli/update-restart-child.ts +4 -2
  53. package/src/cli/update-restart-home.ts +28 -10
  54. package/src/cli/update-restart-service-record.ts +116 -0
  55. package/src/cli/update-restart-supervision.ts +96 -0
  56. package/src/cli/update-restart.ts +59 -13
  57. package/src/cli/v2.ts +27 -20
  58. package/src/cli/version-skew-notice.ts +77 -0
  59. package/src/clients/config-export/model-metadata.ts +16 -0
  60. package/src/clients/config-export/zcode-store.ts +46 -11
  61. package/src/clients/config-export/zcode.ts +3 -5
  62. package/src/clients/config-export.ts +1 -1
  63. package/src/clients/lazycodex.ts +1 -1
  64. package/src/clients/omo-role-models.ts +32 -7
  65. package/src/codex/auth-api/account-selection.ts +107 -0
  66. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  67. package/src/codex/auth-api/routes.ts +10 -49
  68. package/src/codex/auth-context.ts +28 -4
  69. package/src/codex/autostart-health.ts +12 -3
  70. package/src/codex/catalog/build-entries.ts +18 -0
  71. package/src/codex/catalog/bundled.ts +155 -26
  72. package/src/codex/catalog/combo-member.ts +3 -2
  73. package/src/codex/catalog/effort.ts +26 -5
  74. package/src/codex/catalog/gather-capture.ts +5 -0
  75. package/src/codex/catalog/model-hints.ts +15 -8
  76. package/src/codex/catalog/model-visibility.ts +3 -1
  77. package/src/codex/catalog/native-models.ts +1 -1
  78. package/src/codex/catalog/operator-tiers.ts +38 -0
  79. package/src/codex/catalog/provider-models.ts +68 -35
  80. package/src/codex/catalog/retained-sync.ts +7 -5
  81. package/src/codex/catalog/routed-gather.ts +15 -1
  82. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  83. package/src/codex/catalog-auto-refresh.ts +19 -0
  84. package/src/codex/catalog.ts +1 -1
  85. package/src/codex/codex-home-owner.ts +3 -0
  86. package/src/codex/config-write-lock.ts +333 -0
  87. package/src/codex/convergence.ts +2 -1
  88. package/src/codex/desktop-app/darwin.ts +1 -1
  89. package/src/codex/desktop-app/handoff.ts +1 -1
  90. package/src/codex/desktop-app/linux.ts +1 -1
  91. package/src/codex/desktop-app/lock.ts +1 -1
  92. package/src/codex/desktop-app/types.ts +1 -1
  93. package/src/codex/desktop-app/windows.ts +1 -1
  94. package/src/codex/desktop-app-restart.ts +1 -1
  95. package/src/codex/features.ts +257 -200
  96. package/src/codex/inject/config-write-section.ts +34 -0
  97. package/src/codex/inject/multi-agent-v2.ts +14 -6
  98. package/src/codex/inject/remove.ts +36 -11
  99. package/src/codex/inject/restore.ts +85 -20
  100. package/src/codex/inject-coordination.ts +15 -6
  101. package/src/codex/inject.ts +62 -20
  102. package/src/codex/journal.ts +62 -17
  103. package/src/codex/main-account-cache.ts +43 -0
  104. package/src/codex/main-account.ts +6 -7
  105. package/src/codex/paths.ts +2 -0
  106. package/src/codex/prepared-home.ts +18 -0
  107. package/src/codex/prompt-journal.ts +25 -12
  108. package/src/codex/prompt-layers.ts +49 -26
  109. package/src/codex/prompt-lock-claim.ts +134 -0
  110. package/src/codex/prompt-lock-evidence.ts +26 -0
  111. package/src/codex/prompt-lock-io.ts +16 -0
  112. package/src/codex/prompt-lock-owner.ts +130 -0
  113. package/src/codex/prompt-lock.ts +84 -53
  114. package/src/codex/routing/failure-window.ts +146 -0
  115. package/src/codex/routing/selection.ts +4 -1
  116. package/src/codex/routing.ts +13 -17
  117. package/src/codex/runtime.ts +106 -18
  118. package/src/codex/shim.ts +45 -19
  119. package/src/combos/jev-service-exchange.ts +281 -0
  120. package/src/combos/jev.ts +34 -220
  121. package/src/config/atomic-write.ts +1 -1
  122. package/src/config/codex-ws-reuse-setting.ts +10 -0
  123. package/src/config/live-reconcile.ts +17 -0
  124. package/src/config/load-degrade.ts +8 -0
  125. package/src/config/rebase-provenance.ts +11 -0
  126. package/src/config/schema/config-schema.ts +4 -0
  127. package/src/generated/compatibility-version.json +375 -227
  128. package/src/generated/model-metadata.ts +2 -2
  129. package/src/grok/inject.ts +276 -206
  130. package/src/grok/reset-coupon-ledger.ts +223 -57
  131. package/src/grok/reset-coupons.ts +13 -6
  132. package/src/grok/sync.ts +2 -1
  133. package/src/images/loop.ts +29 -11
  134. package/src/integrations/aside-profiles.ts +10 -3
  135. package/src/integrations/catalog-refresh.ts +18 -2
  136. package/src/integrations/cursor-detect.ts +1 -1
  137. package/src/integrations/writer.ts +9 -3
  138. package/src/lib/bun-path-runtime.d.mts +32 -0
  139. package/src/lib/bun-path-runtime.mjs +95 -0
  140. package/src/lib/bun-runtime-preflight.ts +140 -0
  141. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  142. package/src/lib/desktop-cli-handoff.mjs +92 -0
  143. package/src/lib/desktop-cli-record.d.mts +29 -0
  144. package/src/lib/desktop-cli-record.mjs +102 -0
  145. package/src/lib/errors.ts +25 -0
  146. package/src/lib/redact.ts +2 -2
  147. package/src/lib/request-execution-budget.ts +8 -0
  148. package/src/lib/synced-state-location.ts +46 -6
  149. package/src/lib/test-home-guard.ts +87 -1
  150. package/src/lib/translator-budget.ts +17 -4
  151. package/src/lib/windows-atomic-replace.ts +38 -16
  152. package/src/lib/windows-elevation.ts +6 -0
  153. package/src/lib/windows-secret-acl.ts +92 -25
  154. package/src/lib/winsw.ts +2 -3
  155. package/src/oauth/anthropic-account-refusal.ts +39 -1
  156. package/src/oauth/anthropic-routing.ts +1 -1
  157. package/src/oauth/anthropic-send-ownership.ts +3 -3
  158. package/src/oauth/chatgpt.ts +113 -41
  159. package/src/oauth/index.ts +7 -3
  160. package/src/oauth/meta-muse-device.ts +1 -1
  161. package/src/oauth/store.ts +1 -1
  162. package/src/oauth/types.ts +1 -1
  163. package/src/providers/anthropic-fast.ts +1 -1
  164. package/src/providers/anthropic-reset-grant-ledger.ts +1 -1
  165. package/src/providers/azure-model-metadata.ts +115 -0
  166. package/src/providers/derive.ts +21 -0
  167. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  168. package/src/providers/devin-provider-merge-migration.ts +1 -1
  169. package/src/providers/github-copilot-auto.ts +169 -0
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/muse-subscription-usage.ts +1 -1
  172. package/src/providers/quota/vendor-probes-key.ts +127 -17
  173. package/src/providers/quota.ts +1 -1
  174. package/src/providers/registry/entries-core.ts +6 -6
  175. package/src/providers/registry/entries-extended.ts +4 -0
  176. package/src/providers/registry/model-seeds.ts +3 -3
  177. package/src/providers/xai-fast-model.ts +1 -1
  178. package/src/responses/reasoning-envelope.ts +29 -1
  179. package/src/responses/reasoning-replay-cache.ts +17 -1
  180. package/src/responses/spill-store.ts +15 -2
  181. package/src/responses/state/metrics.ts +8 -0
  182. package/src/responses/state/spill-failure.ts +12 -0
  183. package/src/responses/state/spill-queue.ts +12 -5
  184. package/src/responses/state.ts +18 -10
  185. package/src/responses/task-input.ts +17 -0
  186. package/src/server/background-lifecycle.ts +27 -0
  187. package/src/server/chat-completions.ts +1 -0
  188. package/src/server/chat-native.ts +28 -12
  189. package/src/server/claude-messages.ts +43 -6
  190. package/src/server/inbound-body-admission.ts +25 -7
  191. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  192. package/src/server/index/link-listener.ts +30 -17
  193. package/src/server/index/serve-options.ts +18 -4
  194. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  195. package/src/server/index/startup-warnings.ts +19 -0
  196. package/src/server/index.ts +2 -1
  197. package/src/server/lifecycle.ts +31 -4
  198. package/src/server/management/agent-settings-routes.ts +93 -32
  199. package/src/server/management/codex-agent-role-routes.ts +4 -2
  200. package/src/server/management/codex-prompt-routes.ts +1 -0
  201. package/src/server/management/codex-role-auto-assign.ts +13 -2
  202. package/src/server/management/config-routes.ts +59 -8
  203. package/src/server/management/context.ts +2 -2
  204. package/src/server/management/grok-coupon-routes.ts +277 -52
  205. package/src/server/management/link-routes.ts +42 -20
  206. package/src/server/management/route-registry.ts +2 -2
  207. package/src/server/management/shared.ts +3 -3
  208. package/src/server/messages-native-oauth.ts +9 -8
  209. package/src/server/messages-native.ts +49 -13
  210. package/src/server/messages-response-headers.ts +14 -0
  211. package/src/server/relay-eager.ts +2 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-conversation.ts +48 -5
  214. package/src/server/request-log-failure-attribution.ts +3 -0
  215. package/src/server/request-log-terminal-status.ts +186 -0
  216. package/src/server/request-log.ts +15 -89
  217. package/src/server/responses/adapter-continuation.ts +11 -3
  218. package/src/server/responses/adapter-dispatch.ts +99 -19
  219. package/src/server/responses/codex-ws-exchange.ts +17 -2
  220. package/src/server/responses/codex-ws-pool.ts +100 -13
  221. package/src/server/responses/codex-ws-wire.ts +9 -0
  222. package/src/server/responses/combo-requested-effort.ts +3 -1
  223. package/src/server/responses/compact.ts +3 -2
  224. package/src/server/responses/core-codex-account.ts +22 -1
  225. package/src/server/responses/core-combo-native.ts +20 -3
  226. package/src/server/responses/core-combo.ts +22 -14
  227. package/src/server/responses/core-lifetime.ts +3 -36
  228. package/src/server/responses/core-opaque-recovery.ts +5 -5
  229. package/src/server/responses/core-options.ts +5 -0
  230. package/src/server/responses/core-replay.ts +73 -0
  231. package/src/server/responses/core.ts +9 -9
  232. package/src/server/responses/empty-completion-guard.ts +18 -2
  233. package/src/server/responses/fetch-helpers.ts +26 -3
  234. package/src/server/responses/jev-model-invoke.ts +1 -1
  235. package/src/server/responses/passthrough-delivery.ts +24 -2
  236. package/src/server/responses/passthrough-dispatch.ts +134 -62
  237. package/src/server/responses/passthrough-execution.ts +3 -3
  238. package/src/server/responses/request-prepare.ts +4 -0
  239. package/src/server/responses/request-send-budget.ts +112 -49
  240. package/src/server/responses/request-transport.ts +125 -5
  241. package/src/server/responses/response-effects.ts +1 -0
  242. package/src/server/responses/run-turn-execution.ts +15 -5
  243. package/src/server/responses/sidecar-execution.ts +30 -16
  244. package/src/server/responses/sidecar-send-budget.ts +58 -0
  245. package/src/server/responses/spend-storage-error.ts +33 -0
  246. package/src/server/responses/ws-upstream.ts +19 -6
  247. package/src/server/startup-health-cache.ts +2 -1
  248. package/src/server/workflow-refusal.ts +3 -0
  249. package/src/service/cli.ts +20 -4
  250. package/src/service/desktop-command-guard.ts +32 -0
  251. package/src/service/desktop-startup.ts +66 -40
  252. package/src/service/desktop-supervision.d.mts +26 -0
  253. package/src/service/desktop-supervision.mjs +140 -0
  254. package/src/service/managing-cli.ts +1 -0
  255. package/src/service/orchestration.ts +46 -20
  256. package/src/service/repair.ts +25 -9
  257. package/src/service/state.ts +4 -3
  258. package/src/service/systemd.ts +17 -8
  259. package/src/service/windows-ops.ts +61 -22
  260. package/src/storage/policy-job.ts +5 -1
  261. package/src/storage/policy-worker.ts +3 -1
  262. package/src/storage/policy.ts +9 -0
  263. package/src/types/config.ts +21 -0
  264. package/src/types/request.ts +10 -0
  265. package/src/types/wire.ts +1 -0
  266. package/src/update/index.ts +57 -9
  267. package/src/update/restart-ownership.ts +10 -3
  268. package/src/update/runtime-ownership.d.mts +6 -0
  269. package/src/update/runtime-ownership.mjs +23 -7
  270. package/src/usage/expected-prices.ts +1 -1
  271. package/src/web-search/loop.ts +5 -2
  272. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -0,0 +1,169 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import { readBoundedResponseBody } from "../lib/bounded-body";
4
+ import { ProviderOutboundSendCancelledError, providerOutboundGet, providerOutboundPost } from "../lib/provider-outbound";
5
+ import { githubCopilotHttpError, resolveCopilotApiBaseUrl } from "../oauth/github-copilot";
6
+ import { resolveGithubCopilotTransport } from "./github-copilot-transport";
7
+ import { waitForProviderRequestSlot, releaseProviderRequestSlot } from "./request-pacing";
8
+
9
+ export interface CopilotModel {
10
+ id: string;
11
+ model_picker_enabled?: boolean;
12
+ supported_endpoints?: string[];
13
+ [key: string]: unknown;
14
+ }
15
+ const catalogs = new Map<string, { expires: number; models: CopilotModel[] }>();
16
+ const MAX_MODELS = 512;
17
+ const MAX_RESPONSE_BYTES = 1_048_576;
18
+ export class CopilotAutoHttpError extends Error {
19
+ /** Preserve only the upstream status and bounded retry delay; never retain response bodies. */
20
+ constructor(readonly status: number, readonly retryAfter?: string) {
21
+ super(githubCopilotHttpError("Auto negotiation", status).message);
22
+ }
23
+ }
24
+ /** Reject array/scalar payloads before interpreting negotiation objects. */
25
+ function record(value: unknown): Record<string, unknown> | undefined {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value)
27
+ ? value as Record<string, unknown> : undefined;
28
+ }
29
+ /** Accept a bounded, homogeneous list so malformed pools cannot grant routing eligibility. */
30
+ function strings(value: unknown): string[] {
31
+ return Array.isArray(value) && value.length <= MAX_MODELS
32
+ && value.every(/** Require every pool entry to be a bounded nonempty string before it can authorize model selection. */
33
+ v => typeof v === "string" && v.length > 0 && v.length <= 256)
34
+ ? value as string[] : [];
35
+ }
36
+ /** Isolate discovery caches by endpoint and credential without storing plaintext cache keys. */
37
+ function authority(provider: OcxProviderConfig): string {
38
+ return createHash("sha256").update(JSON.stringify([provider.baseUrl, provider.apiKey])).digest("hex");
39
+ }
40
+ /** Make one paced, origin-fenced negotiation send with bounded decoding and secret-safe errors. */
41
+ async function requestJson(provider: OcxProviderConfig, path: string, signal?: AbortSignal, body?: unknown,
42
+ sessionToken?: string, beforeSend?: () => boolean, concurrency = true): Promise<Record<string, unknown>> {
43
+ const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(8_000)]) : AbortSignal.timeout(8_000);
44
+ boundedSignal.throwIfAborted();
45
+ const headers = new Headers(provider.headers);
46
+ headers.set("Authorization", `Bearer ${provider.apiKey}`);
47
+ headers.set("X-GitHub-Api-Version", "2026-08-01");
48
+ headers.set("Content-Type", "application/json");
49
+ headers.delete("Copilot-Session-Token");
50
+ if (sessionToken) headers.set("Copilot-Session-Token", sessionToken);
51
+ const url = `${provider.baseUrl}${path}`;
52
+ // Queued-dispatch rebuilding can already own a provider concurrency lease; that
53
+ // caller opts out of taking a second lease while retaining interval pacing.
54
+ const slot = await waitForProviderRequestSlot("github-copilot", provider, "auto", boundedSignal, { concurrency });
55
+ try {
56
+ const init = { headers, signal: boundedSignal };
57
+ const response = body === undefined
58
+ ? await providerOutboundGet("github-copilot", provider, url, init, { beforeSend })
59
+ : await providerOutboundPost("github-copilot", provider, url, { ...init, body: JSON.stringify(body) }, { beforeSend });
60
+ // Status-only failures: session endpoints can echo secret material in their bodies.
61
+ if (!response.ok) {
62
+ void response.body?.cancel().catch(/** Ignore cleanup failures after discarding a refusal body that may contain reflected credentials. */
63
+ () => undefined);
64
+ const retry = response.headers.get("retry-after");
65
+ throw new CopilotAutoHttpError(response.status, retry && /^\d{1,4}$/.test(retry)
66
+ ? String(Math.max(1, Math.min(3600, Number(retry)))) : undefined);
67
+ }
68
+ const bounded = await readBoundedResponseBody(response, { maxBytes: MAX_RESPONSE_BYTES,
69
+ signal: boundedSignal, totalTimeoutMs: 8_000, fatalUtf8: true });
70
+ if (bounded.truncated) throw new Error("GitHub Copilot Auto negotiation returned an incomplete response");
71
+ let value: unknown;
72
+ try { value = JSON.parse(bounded.text); } catch { /* never include raw bodies */ }
73
+ const payload = record(value);
74
+ if (!payload) throw new Error("GitHub Copilot Auto negotiation returned invalid JSON");
75
+ return payload;
76
+ } catch (error) {
77
+ if (error instanceof CopilotAutoHttpError || error instanceof ProviderOutboundSendCancelledError) throw error;
78
+ // Transport/decoder exception messages may contain reflected header values.
79
+ throw new Error(boundedSignal.aborted ? "GitHub Copilot Auto negotiation cancelled"
80
+ : "GitHub Copilot Auto negotiation transport failed");
81
+ } finally { releaseProviderRequestSlot(slot); }
82
+ }
83
+
84
+ /** Detect Auto-only permission only when every row explicitly denies named selection. */
85
+ export function copilotRequiresAuto(_provider: OcxProviderConfig, models: CopilotModel[]): boolean {
86
+ return models.length > 0 && models.every(/** Missing or malformed metadata cannot establish an account-wide picker denial. */
87
+ model => model.model_picker_enabled === false);
88
+ }
89
+ /** Retain legacy rows with unknown permissions; only an explicit denial removes a named picker row. */
90
+ export function copilotPickerModels(provider: OcxProviderConfig, models: CopilotModel[]): CopilotModel[] {
91
+ if (copilotRequiresAuto(provider, models)) return [{ id: "auto" }];
92
+ return models.filter(/** Preserve named selection unless the provider explicitly marks the row unavailable. */
93
+ model => model.model_picker_enabled !== false);
94
+ }
95
+ /** Read the credential-bound catalog; callers may disable caching or reuse an existing lease. */
96
+ export async function fetchCopilotAutoModels(provider: OcxProviderConfig, signal?: AbortSignal,
97
+ beforeSend?: () => boolean, cacheTtlMs = 60_000, concurrency = true): Promise<CopilotModel[]> {
98
+ const key = authority(provider);
99
+ const cached = catalogs.get(key);
100
+ if (cacheTtlMs > 0 && cached && cached.expires > Date.now()) return cached.models;
101
+ const payload = await requestJson(provider, "/models", signal, undefined, undefined, beforeSend, concurrency);
102
+ if (!Array.isArray(payload.data) || payload.data.length > MAX_MODELS)
103
+ throw new Error("GitHub Copilot model discovery returned an invalid catalog");
104
+ const models = payload.data.flatMap(/** Discard malformed catalog rows while retaining metadata for bounded model identifiers. */
105
+ value => {
106
+ const row = record(value);
107
+ if (!row || typeof row.id !== "string" || !row.id || row.id.length > 256) return [];
108
+ return [{ ...row, id: row.id } as CopilotModel];
109
+ });
110
+ if (cacheTtlMs > 0) {
111
+ if (!catalogs.has(key) && catalogs.size >= 32) catalogs.delete(catalogs.keys().next().value!);
112
+ catalogs.set(key, { expires: Date.now() + Math.min(cacheTtlMs, 60_000), models });
113
+ }
114
+ return models;
115
+ }
116
+ /** Reset discovery state between isolated account/permission regression cases. */
117
+ export function clearCopilotAutoModelsForTests(): void { catalogs.clear(); }
118
+
119
+ /** Resolve before adapter construction: the server can choose either wire on every turn. */
120
+ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedModel: string,
121
+ parsed: OcxParsedRequest, signal?: AbortSignal, beforeSend?: () => boolean, concurrency = true): Promise<{
122
+ provider: OcxProviderConfig; modelId: string; auto: boolean; expiresAt?: number;
123
+ }> {
124
+ const transport = resolveGithubCopilotTransport(provider, provider.authMode === "oauth"
125
+ ? resolveCopilotApiBaseUrl(provider.baseUrl) : undefined);
126
+ // Never inherit a session token from a previous account or persist it in config.
127
+ const cleanHeaders = new Headers(transport.headers);
128
+ cleanHeaders.delete("Copilot-Session-Token");
129
+ transport.headers = Object.fromEntries(cleanHeaders);
130
+ let models: CopilotModel[];
131
+ try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); }
132
+ catch (error) {
133
+ if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error;
134
+ // Permission discovery is additive for legacy paid configurations. A temporary
135
+ // discovery outage must not turn a previously usable named route into a refusal.
136
+ return { provider: transport, modelId: requestedModel, auto: false };
137
+ }
138
+ if (!copilotRequiresAuto(provider, models))
139
+ return { provider: transport, modelId: requestedModel, auto: false };
140
+ const session = await requestJson(transport, "/models/session", signal,
141
+ { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
142
+ const pool = strings(session.available_models);
143
+ if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length)
144
+ throw new Error("GitHub Copilot Auto session omitted its routing credentials");
145
+ if (session.expires_at !== undefined && (typeof session.expires_at !== "number" || !Number.isFinite(session.expires_at)))
146
+ throw new Error("GitHub Copilot Auto session returned an invalid expiry");
147
+ const expiresAt = typeof session.expires_at === "number" && Number.isFinite(session.expires_at)
148
+ ? session.expires_at * 1000 : Date.now() + 60_000;
149
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000) throw new Error("GitHub Copilot Auto session is already expired");
150
+ const lastUser = [...parsed.context.messages].reverse().find(/** Find the newest user turn as the intent-routing source instead of replaying earlier conversation text. */
151
+ message => message.role === "user");
152
+ const prompt = lastUser?.role === "user"
153
+ ? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */
154
+ part => part.type === "text" ? [part.text] : []).join("\n")
155
+ : "";
156
+ const intent = await requestJson(transport, "/models/session/intent", signal,
157
+ { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
158
+ const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */
159
+ candidate => pool.includes(candidate));
160
+ if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model");
161
+ const endpoints = strings(models.find(/** Read endpoint capabilities for the selected model before choosing its inference adapter. */
162
+ model => model.id === modelId)?.supported_endpoints);
163
+ const adapter = endpoints.includes("/responses") ? "openai-responses"
164
+ : endpoints.includes("/chat/completions") ? "openai-chat" : undefined;
165
+ if (!adapter) throw new Error("GitHub Copilot Auto selected a model with no supported inference endpoint");
166
+ return { auto: true, modelId, expiresAt: Math.min(expiresAt, Date.now() + 60_000), provider: { ...transport, adapter,
167
+ ...(adapter === "openai-responses" ? { responsesPath: "/responses" } : { chatCompletionsPath: "/chat/completions" }),
168
+ headers: { ...transport.headers, "X-GitHub-Api-Version": "2026-08-01", "Copilot-Session-Token": session.session_token } } };
169
+ }
@@ -12,7 +12,7 @@
12
12
  * The preset is a SEED, not a lock. Materialization evaluates these rules against the provider's
13
13
  * current catalog and stores CONCRETE ids in `selectedModels`, so the visibility hot path
14
14
  * (`filterCatalogVisibleModels`) never learns about patterns and an older binary still sees a
15
- * plain allowlist. Design: devlog/_plan/260824_model_ux_aliases_and_defaults/030_default_preset.md
15
+ * plain allowlist. Design: devlog/_fin/260824_model_ux_aliases_and_defaults/030_default_preset.md
16
16
  */
17
17
 
18
18
  import type { OcxProviderConfig } from "../types";
@@ -99,7 +99,7 @@ export function museUsageWindowsToQuota(usage: unknown): ProviderQuota | null {
99
99
  *
100
100
  * Kept as a thin adapter over the shared mapper: the frame nests the same two windows
101
101
  * under `subscription`, the muse-code/key response nests them under `subs_usage`
102
- * (devlog/_plan/260912_muse_device_oauth/001 C). Two parsers would drift, and the
102
+ * (devlog/_fin/260912_muse_device_oauth/001 C). Two parsers would drift, and the
103
103
  * five-hour-window discrimination above is the part that must not.
104
104
  */
105
105
  export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null {
@@ -38,6 +38,7 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
38
38
  const CLINE_BASE_URL = "https://api.cline.bot";
39
39
  const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
40
40
  const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`;
41
+ const OLLAMA_CLOUD_BALANCE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/balance`;
41
42
  const ZAI_BASE_URL = "https://api.z.ai";
42
43
  const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
43
44
  const MINIMAX_REMAINS_PATH = "/v1/api/openplatform/coding_plan/remains";
@@ -439,11 +440,80 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
439
440
  }
440
441
 
441
442
  /**
442
- * Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage.
443
- * Legacy plans report rolling 5-hour `limits.session.usage` and 7-day
444
- * `limits.weekly.usage`. Migrated monthly-credit plans report
445
- * `limits.monthly.usage`. `usage` values are normalized fractions (0..1).
443
+ * Ollama Cloud quota. `GET https://ollama.com/api/balance` is the live source: it
444
+ * returns the included (plan) balance plus purchased credits. Legacy plans report
445
+ * rolling 5-hour `included.session.remaining_percent` and 7-day
446
+ * `included.weekly.remaining_percent` (remaining shares, so used = 100 - remaining);
447
+ * credit plans report `included.balance_usd`/`included.allowance_usd` over a
448
+ * `period`.
449
+ *
450
+ * `GET https://ollama.com/api/usage` used to carry `limits.{session,weekly,monthly}.usage`
451
+ * as normalized fractions but now returns only a request-count timeseries, so it is
452
+ * kept as a fallback for older deployments.
446
453
  */
454
+ function parseOllamaUsedPercent(row: Record<string, unknown>): number | undefined {
455
+ if (row.remaining_percent !== undefined) {
456
+ const remaining = toFiniteNumber(row.remaining_percent);
457
+ if (remaining === undefined) return undefined;
458
+ return normalizePercent(Math.round((100 - remaining) * 100) / 100);
459
+ }
460
+ return parseOllamaPercent(row.usage);
461
+ }
462
+
463
+ export function parseOllamaCloudBalance(body: Record<string, unknown> | null): ProviderQuota | null {
464
+ if (!body) return null;
465
+ const included = asRecord(body.included);
466
+ if (!included) return null;
467
+
468
+ const quota: ProviderQuota = { updatedAt: Date.now() };
469
+ let windows = 0;
470
+
471
+ const rows: Array<[string, "fiveHour" | "weekly" | "monthly"]> = [
472
+ ["session", "fiveHour"],
473
+ ["weekly", "weekly"],
474
+ ["monthly", "monthly"],
475
+ ];
476
+ for (const [key, window] of rows) {
477
+ const row = asRecord(included[key]);
478
+ if (!row) continue;
479
+ const percent = parseOllamaUsedPercent(row);
480
+ if (percent === undefined) continue;
481
+ const resetAt = normalizeResetAt(row.resets_at);
482
+ if (window === "fiveHour") {
483
+ quota.fiveHourPercent = percent;
484
+ if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
485
+ } else if (window === "weekly") {
486
+ quota.weeklyPercent = percent;
487
+ if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
488
+ } else {
489
+ quota.monthlyPercent = percent;
490
+ if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
491
+ }
492
+ windows += 1;
493
+ }
494
+
495
+ const allowance = toFiniteNumber(included.allowance_usd);
496
+ const balance = toFiniteNumber(included.balance_usd);
497
+ if (allowance !== undefined && allowance > 0 && balance !== undefined) {
498
+ const used = Math.max(0, allowance - balance);
499
+ const percent = normalizePercent(Math.round((used / allowance) * 10000) / 100);
500
+ if (percent !== undefined) {
501
+ const expiresAt = normalizeResetAt(asRecord(included.period)?.until);
502
+ // creditsUsd is the included allowance only; purchased credits are ignored.
503
+ quota.creditsUsd = {
504
+ used,
505
+ limit: allowance,
506
+ remaining: balance,
507
+ percent,
508
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
509
+ };
510
+ windows += 1;
511
+ }
512
+ }
513
+
514
+ return windows > 0 ? quota : null;
515
+ }
516
+
447
517
  function parseOllamaPercent(usageValue: unknown): number | undefined {
448
518
  const usage = toFiniteNumber(usageValue);
449
519
  if (usage === undefined || usage < 0) return undefined;
@@ -489,25 +559,65 @@ export function parseOllamaCloudQuota(body: Record<string, unknown> | null): Pro
489
559
  return windows > 0 ? quota : null;
490
560
  }
491
561
 
562
+ /**
563
+ * Routing projection for an `/api/balance` report. `creditsUsd` covers the included
564
+ * allowance only, so it may veto routing only when purchased credit is known to be empty;
565
+ * otherwise an exhausted allowance would block an account that still has paid balance.
566
+ */
567
+ function ollamaBalanceInferenceQuota(quota: ProviderQuota, body: Record<string, unknown> | null): ProviderQuota {
568
+ if (!quota.creditsUsd) return quota;
569
+ const purchased = toFiniteNumber(asRecord(body?.purchased)?.balance_usd);
570
+ if (purchased !== undefined && purchased <= 0) return quota;
571
+ const { creditsUsd: _includedOnly, ...inference } = quota;
572
+ return inference;
573
+ }
574
+
492
575
  async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
493
576
  const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? "";
494
577
  if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null;
495
578
  const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
496
579
  if (!apiKey) return null;
497
- const response = await quotaFetch(provider, config, OLLAMA_CLOUD_USAGE_URL, {
498
- headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
499
- redirect: "error",
500
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
501
- });
502
- if (!response.ok) {
503
- if (response.status === 404) return null;
504
- return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
505
- ? TERMINAL_QUOTA_FAILURE
506
- : null;
580
+ const attempts: Array<{
581
+ url: string;
582
+ source: string;
583
+ parse: (body: Record<string, unknown> | null) => ProviderQuota | null;
584
+ }> = [
585
+ { url: OLLAMA_CLOUD_BALANCE_URL, source: "ollama-cloud:balance", parse: parseOllamaCloudBalance },
586
+ { url: OLLAMA_CLOUD_USAGE_URL, source: "ollama-cloud:usage", parse: parseOllamaCloudQuota },
587
+ ];
588
+ let terminalFailure = false;
589
+ for (const attempt of attempts) {
590
+ let response: Response;
591
+ try {
592
+ response = await quotaFetch(provider, config, attempt.url, {
593
+ headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
594
+ redirect: "error",
595
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
596
+ });
597
+ } catch {
598
+ continue;
599
+ }
600
+ if (!response.ok) {
601
+ if (response.status !== 404 && response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
602
+ terminalFailure = true;
603
+ }
604
+ try {
605
+ void response.body?.cancel().catch(() => undefined);
606
+ } catch {
607
+ // Best-effort cancellation only.
608
+ }
609
+ continue;
610
+ }
611
+ const raw = await readQuotaJson(response);
612
+ if (raw === QUOTA_JSON_READ_FAILURE) return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
613
+ const body = asRecord(raw);
614
+ const quota = attempt.parse(body);
615
+ if (quota) {
616
+ const inference = attempt.url === OLLAMA_CLOUD_BALANCE_URL ? ollamaBalanceInferenceQuota(quota, body) : quota;
617
+ return keyReport(provider, attempt.source, quota, config, apiKey, inference);
618
+ }
507
619
  }
508
- const body = asRecord(await readQuotaJson(response));
509
- const quota = parseOllamaCloudQuota(body);
510
- return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null;
620
+ return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
511
621
  }
512
622
 
513
623
  /**
@@ -97,7 +97,7 @@ export {
97
97
  type ProviderAccountQuota,
98
98
  } from "./quota/account-cache";
99
99
  export { fetchAntigravityUsageQuota, isCanonicalAntigravityQuotaUrl, setAntigravityAccountQuotaTransportForTests } from "./quota/antigravity";
100
- export { parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
100
+ export { parseOllamaCloudBalance, parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
101
101
  export { parseXaiCreditsResponse } from "./quota/vendor-probes-oauth";
102
102
  export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise<ProviderApiKeyQuota[]> {
103
103
  const provider = config.providers[name];
@@ -94,7 +94,7 @@ import {
94
94
  * Only the models Anthropic documents for the lane are classified; Opus 4.6 silently runs
95
95
  * standard and Opus 4.7, Sonnet, Haiku and Fable reject `speed`, so they and future ids stay
96
96
  * unclassified. Source: https://platform.claude.com/docs/en/build-with-claude/fast-mode
97
- * (2026-09-23) and the live probe in devlog/_plan/260923_anthropic_fast_speed.
97
+ * (2026-09-23) and the live probe in devlog/_fin/260923_anthropic_fast_speed.
98
98
  */
99
99
  const ANTHROPIC_FAST_WIRE = Object.freeze({
100
100
  kind: "anthropic-speed" as const,
@@ -209,7 +209,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
209
209
  },
210
210
  {
211
211
  // The canonical Cognition account provider, after absorbing `devin-cli`
212
- // (devlog/_plan/260913_devin_provider_merge). The two ids were the same
212
+ // (devlog/_fin/260913_devin_provider_merge). The two ids were the same
213
213
  // `devin` adapter, the same server.codeium.com api-server, and the same
214
214
  // `devin-session-token$<JWT>` credential — only the account source
215
215
  // differed: this entry did an Auth0 browser sign-in while `devin-cli`
@@ -273,7 +273,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
273
273
  // the field but answers service_tier "default" — a live downgrade, not a fast tier.
274
274
  // Unlisted and future-discovered ids stay unclassified.
275
275
  // grok-4.7 applied and confirmed priority on OAuth Responses in the 2026-09-23
276
- // live probe: devlog/_plan/260923_grok47_parity/010_probe-evidence.md.
276
+ // live probe: devlog/_fin/260923_grok47_parity/010_probe-evidence.md.
277
277
  modelSupportsServiceTier: {
278
278
  "grok-4.7": true,
279
279
  "grok-4.6": true,
@@ -320,7 +320,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
320
320
  models: XAI_MODELS,
321
321
  // grok-4.7-build-fast arrives only through OAuth discovery: xAI's Grok 4.7 Fast, "the same model
322
322
  // served on faster infrastructure" (docs.x.ai/developers/grok-4-7). Probed 2026-09-30
323
- // (devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md): identical effort ladder, image
323
+ // (devlog/_fin/260930_grok47_build_unify/010_probe-evidence.md): identical effort ladder, image
324
324
  // input, 500k limit and advertised defaults, 1.5-1.7x faster, ~2x the ticks per output token. It is
325
325
  // not a second model row: shouldExposeProviderModel hides it, and grok-4.7's Fast selection on OAuth
326
326
  // serializes it (src/providers/xai-fast-model.ts). It keeps grok-4.7's facts in the lists below so an
@@ -450,7 +450,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
450
450
  // documents high as the 4.6 default but no multi-agent default, so do not invent one.
451
451
  modelReasoningEfforts: {
452
452
  // 2026-09-23 live probe accepted low..xhigh and rejected max on both wires;
453
- // devlog/_plan/260923_grok47_parity/010_probe-evidence.md.
453
+ // devlog/_fin/260923_grok47_parity/010_probe-evidence.md.
454
454
  "grok-4.7": ["low", "medium", "high", "xhigh"],
455
455
  "grok-4.7-build-fast": ["low", "medium", "high", "xhigh"],
456
456
  "grok-4.6": ["low", "medium", "high", "xhigh"],
@@ -460,7 +460,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
460
460
  modelDefaultReasoningEfforts: { "grok-4.7": "high", "grok-4.7-build-fast": "high", "grok-4.6": "high" },
461
461
  modelContextWindows: {
462
462
  // 500k confirmed by context_length_exceeded:
463
- // devlog/_plan/260923_grok47_parity/010_probe-evidence.md.
463
+ // devlog/_fin/260923_grok47_parity/010_probe-evidence.md.
464
464
  "grok-4.7": 500_000,
465
465
  "grok-4.7-build-fast": 500_000,
466
466
  "grok-4.6": 500_000,
@@ -1176,6 +1176,10 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
1176
1176
  "openai/o3-pro": ["text"],
1177
1177
  },
1178
1178
  noVisionModels: ["openai/o3-pro"],
1179
+ // #6877: on the Responses wire OpenGateway answers 400 once history replays native
1180
+ // custom_tool_call/custom_tool_call_output items (Codex code-mode exec), and accepts the
1181
+ // same request after the custom tool is lowered to a function. Same contract as xAI.
1182
+ supportsResponsesCustomTools: false,
1179
1183
  // Chat is the broad wire (77/80 active rows). o3-pro is Responses-only, on all
1180
1184
  // inbounds. Only the two live-verified Sionic models bypass translation for Codex.
1181
1185
  // Other OpenAI/Anthropic/Gemini rows stay on Chat until their Responses tool replay
@@ -129,7 +129,7 @@ export const ZAI_GLM_5X_SIDECAR_VISION_MODELS = ZAI_GLM_5X_MODELS.filter(id => i
129
129
  *
130
130
  * Authoritative source: `GET https://api.z.ai/api/v1/models` returns `input_modalities:
131
131
  * ["text"]` for glm-5.3 and `["text", "image"]` for glm-5.3-flash (captured in
132
- * devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json).
132
+ * devlog/_fin/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json).
133
133
  * docs.z.ai/devpack/latest-model says the same in prose: "GLM-5.3 is a text-only model...
134
134
  * GLM-5.3-FLASH is a multimodal model". Upstream also lists video and file for Flash;
135
135
  * neither the internal vocabulary nor the export vocabulary can express them, so `image`
@@ -158,7 +158,7 @@ export const ZAI_GLM_5X_REASONING_EFFORTS: Record<string, string[]> = {
158
158
  // 260930 MiniMax-M3.1-Flash-Preview: Token Plan / MiniMax Code only, 1M context, thinking
159
159
  // always on (effort none or thinking disabled answers 400 code 2013), omitted effort = max.
160
160
  // It returns thinking as reasoning_content and ignores reasoning_split. The live /v1/models
161
- // roster does not list it yet. Evidence: devlog/_plan/260930_minimax_m31_flash_preview/.
161
+ // roster does not list it yet. Evidence: devlog/_fin/260930_minimax_m31_flash_preview/.
162
162
  export const MINIMAX_M31_FLASH_PREVIEW = "MiniMax-M3.1-Flash-Preview";
163
163
  export const MINIMAX_MODELS = [
164
164
  MINIMAX_M31_FLASH_PREVIEW,
@@ -416,7 +416,7 @@ export const COMMAND_CODE_IMAGE_MODELS = [
416
416
  // Live 2026-09-23 3x3 random-color grid (180x180) via ocx 2.62.0:
417
417
  // 4.7 read 9/9 in user messages and tool results; 4.6 read 9/9 and 8/9.
418
418
  // Neither route requested a vision sidecar. Evidence:
419
- // devlog/_plan/260923_grok47_parity/010_probe-evidence.md.
419
+ // devlog/_fin/260923_grok47_parity/010_probe-evidence.md.
420
420
  "xai/grok-4.6",
421
421
  "xai/grok-4.7",
422
422
  // Native Z.AI VLM (docs.z.ai/guides/vlm/glm-5.3-flash). This exact id is already
@@ -7,7 +7,7 @@ import type { FastWire, OcxParsedRequest, OcxProviderConfig, TierDecision } from
7
7
  * on two lanes (same effort ladder, image input, 500k limit and advertised defaults). Measured live on
8
8
  * 2026-09-30: build-fast is 1.5-1.7x faster end to end, while `service_tier: "priority"` on grok-4.7 bought
9
9
  * no measurable speed and cost ~5.9x the ticks per output token (build-fast costs ~2x).
10
- * Evidence: devlog/_plan/260930_grok47_build_unify/010_probe-evidence.md.
10
+ * Evidence: devlog/_fin/260930_grok47_build_unify/010_probe-evidence.md.
11
11
  *
12
12
  * So on the OAuth lane only, a Fast grok-4.7 request is SERIALIZED as the build-fast id and sends no tier.
13
13
  * The logical id stays grok-4.7 everywhere else (parsed.modelId, route, policy, usage attempt), so effort,
@@ -36,6 +36,32 @@ export interface ReasoningEnvelope {
36
36
  * same way a signature does.
37
37
  */
38
38
  krc?: string;
39
+ /**
40
+ * A Responses provider's own `encrypted_content` for one reasoning item, minted while serving
41
+ * `model` (the client-facing model the Claude route was asked for). The Claude route has no
42
+ * other channel to hand a provider its reasoning back: the body is `store: false` and Claude
43
+ * Code replays only the thinking block. `src/claude/inbound.ts` restores it only for a request
44
+ * naming the same model, so a model switch cannot carry it to a provider that cannot decrypt it.
45
+ */
46
+ nat?: NativeReasoning;
47
+ }
48
+
49
+ export interface NativeReasoning {
50
+ /** The provider's opaque `encrypted_content`, verbatim. */
51
+ enc: string;
52
+ /** Client-facing model the item was produced for. */
53
+ model: string;
54
+ /** Route-bound HMAC for this exact ciphertext. */
55
+ tag: string;
56
+ /** The provider's reasoning item id, when it sent one. */
57
+ id?: string;
58
+ }
59
+
60
+ function decodeNativeReasoning(value: unknown): NativeReasoning | undefined {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
62
+ const { enc, model, id, tag } = value as { enc?: unknown; model?: unknown; id?: unknown; tag?: unknown };
63
+ if (typeof enc !== "string" || enc.length === 0 || typeof model !== "string" || model.length === 0 || typeof tag !== "string" || tag.length === 0) return undefined;
64
+ return typeof id === "string" && id.length > 0 ? { enc, model, tag, id } : { enc, model, tag };
39
65
  }
40
66
 
41
67
  export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string {
@@ -116,7 +142,9 @@ export function decodeReasoningEnvelope(encryptedContent: string, budget?: Trans
116
142
  if (hasTxt) envelope.txt = txt;
117
143
  const krc = (parsed as { krc?: unknown }).krc;
118
144
  if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
119
- return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null;
145
+ const nat = decodeNativeReasoning((parsed as { nat?: unknown }).nat);
146
+ if (nat) envelope.nat = nat;
147
+ return envelope.sig || envelope.red || hasTxt || envelope.krc || envelope.nat ? envelope : null;
120
148
  } catch {
121
149
  return null;
122
150
  } finally {
@@ -19,7 +19,8 @@
19
19
  * long-lived proxy cannot grow without limit.
20
20
  */
21
21
 
22
- import { createHash, createHmac, randomBytes } from "node:crypto";
22
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
23
+ import { thoughtSignatureReplaySalt } from "./thought-signature-replay";
23
24
  import type {
24
25
  OcxProviderConfig,
25
26
  OcxReasoningReplayIdentity,
@@ -27,6 +28,21 @@ import type {
27
28
  } from "../types";
28
29
 
29
30
  const MAX_ENTRIES = 64;
31
+ export interface NativeReasoningOwner { destination: string; credential: string }
32
+
33
+ /** Bind exact ciphertext to the durable route that accepted it. */
34
+ export function nativeReasoningTag(owner: NativeReasoningOwner | undefined, blob: string): string | undefined {
35
+ const salt = thoughtSignatureReplaySalt();
36
+ if (!owner || !salt || salt.length < 16 || blob.length === 0) return undefined;
37
+ return createHmac("sha256", salt).update("native-reasoning\0")
38
+ .update(JSON.stringify([owner.destination, owner.credential, createHash("sha256").update(blob).digest("hex")]))
39
+ .digest("hex");
40
+ }
41
+
42
+ export function nativeReasoningTagMatches(expected: string | undefined, presented: string | undefined): boolean {
43
+ if (!expected || !presented || !/^[a-f0-9]{64}$/.test(expected) || !/^[a-f0-9]{64}$/.test(presented)) return false;
44
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(presented, "hex"));
45
+ }
30
46
  const MAX_TOTAL_BYTES = 256 * 1024;
31
47
  const TTL_MS = 60 * 60 * 1000;
32
48
  const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
@@ -765,10 +765,23 @@ export function readResponseSpill(responseId: string, ref: ResponseSpillRef): Re
765
765
  export function deleteResponseSpill(ref: ResponseSpillRef): void {
766
766
  if (!validSpillRef(ref)) return;
767
767
  const dir = responseSpillDirectory();
768
+ const path = join(dir, ref.fileName);
768
769
  try {
769
- unlink(join(dir, ref.fileName));
770
+ unlink(path);
770
771
  fsyncDirectoryBestEffort(dir);
771
- } catch { /* best effort */ }
772
+ } catch (error) {
773
+ // Still best effort, but no longer invisible: a file a failed unlink left behind occupies
774
+ // the volume, so the accounting owner keeps pricing it until the path is gone (#6747).
775
+ if (!isErrno(error, "ENOENT")) spillUnlinkFailureObserver?.(path, ref.payloadBytes);
776
+ }
777
+ }
778
+
779
+ type SpillUnlinkFailureObserver = (path: string, bytes: number) => void;
780
+ let spillUnlinkFailureObserver: SpillUnlinkFailureObserver | null = null;
781
+
782
+ /** The owner of spill accounting registers here so a failed unlink cannot under-report disk use. */
783
+ export function setResponseSpillUnlinkFailureObserver(next: SpillUnlinkFailureObserver | null): void {
784
+ spillUnlinkFailureObserver = next;
772
785
  }
773
786
 
774
787
  type SpillDirNameKind = "spill" | "temp";
@@ -18,6 +18,10 @@ export interface ResponseStateMetrics {
18
18
  spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null;
19
19
  spillAclRetryReturnedTimeouts: number;
20
20
  spillAclTimeoutMemoRefusals: number;
21
+ /** Publications refused because the durable spill cap could not make room (cumulative). */
22
+ spillCapacityRefusals: number;
23
+ /** Spills evicted by admission to make room for a newer publication (cumulative). */
24
+ spillHeadroomEvictions: number;
21
25
  spillLastWriteFailureAt: number | null;
22
26
  spillLastWriteSuccessAt: number | null;
23
27
  spillReadFailures: number;
@@ -37,6 +41,8 @@ export function computeResponseStateMetrics(params: {
37
41
  spillLastSuccessAt: number | null;
38
42
  spillAclRetryReturnedTimeouts: number;
39
43
  spillAclTimeoutMemoRefusals: number;
44
+ spillCapacityRefusals: number;
45
+ spillHeadroomEvictions: number;
40
46
  spillLastFailureAt: number | null;
41
47
  spillReadFailures: number;
42
48
  replayScopeMismatchDrops: number;
@@ -79,6 +85,8 @@ export function computeResponseStateMetrics(params: {
79
85
  spillLastWriteFailureOrigin: params.spillLastFailureOrigin,
80
86
  spillAclRetryReturnedTimeouts: params.spillAclRetryReturnedTimeouts,
81
87
  spillAclTimeoutMemoRefusals: params.spillAclTimeoutMemoRefusals,
88
+ spillCapacityRefusals: params.spillCapacityRefusals,
89
+ spillHeadroomEvictions: params.spillHeadroomEvictions,
82
90
  spillLastWriteFailureAt: params.spillLastFailureAt,
83
91
  spillLastWriteSuccessAt: params.spillLastSuccessAt,
84
92
  spillReadFailures: params.spillReadFailures,
@@ -1,8 +1,14 @@
1
1
  export const spillCounters = {
2
2
  writes: 0, writeFailures: 0, readFailures: 0,
3
3
  aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0,
4
+ capacityRefusals: 0, headroomEvictions: 0,
4
5
  };
5
6
 
7
+ /** Test-only: zero every spill counter, including ones added later. */
8
+ export function resetSpillCountersForTests(): void {
9
+ for (const key of Object.keys(spillCounters) as Array<keyof typeof spillCounters>) spillCounters[key] = 0;
10
+ }
11
+
6
12
  export type ResponseSpillWriteFailureCode =
7
13
  | "EACLRETRYEXHAUSTED"
8
14
  | "ETIMEDOUT"
@@ -102,6 +108,12 @@ export function noteSpillWriteFailure(
102
108
  if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1;
103
109
  else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1;
104
110
  }
111
+
112
+ /** A publication refused because the durable spill cap could not make room for it (#6747). */
113
+ export function noteSpillCapacityRefusal(): void {
114
+ spillCounters.capacityRefusals += 1;
115
+ noteSpillWriteFailure(null, "ECAPACITY");
116
+ }
105
117
  /**
106
118
  * Admission-boundary observability (test-visible). directSpills: oversized
107
119
  * candidates routed straight to durable spill without a resident stay or