@bitkyc08/opencodex 2.26.0 → 2.27.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 (55) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +1 -1
  5. package/src/adapters/base.ts +14 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google.ts +1 -1
  10. package/src/adapters/openai-chat.ts +38 -6
  11. package/src/adapters/tool-catalog-nudge.ts +1 -1
  12. package/src/bridge.ts +11 -5
  13. package/src/cli/doctor.ts +76 -0
  14. package/src/cli/help.ts +2 -0
  15. package/src/cli/models.ts +13 -6
  16. package/src/codex/app-server-processes.ts +269 -37
  17. package/src/codex/auth-context.ts +53 -1
  18. package/src/codex/catalog/aggregation.ts +3 -0
  19. package/src/codex/catalog/parsing.ts +20 -3
  20. package/src/codex/catalog/provider-fetch.ts +8 -0
  21. package/src/codex/catalog/sync.ts +6 -4
  22. package/src/codex/log-guard/path-safety.ts +52 -3
  23. package/src/codex/native-profile-startup.ts +100 -2
  24. package/src/codex/user-identity.ts +21 -1
  25. package/src/config/provider-name.ts +24 -0
  26. package/src/config.ts +11 -24
  27. package/src/generated/compatibility-version.json +73 -45
  28. package/src/images/loop.ts +11 -4
  29. package/src/lib/state-store-registrations.ts +8 -2
  30. package/src/providers/antigravity-models.ts +70 -5
  31. package/src/providers/derive.ts +12 -2
  32. package/src/providers/registry.ts +46 -1
  33. package/src/providers/service-tier.ts +34 -7
  34. package/src/responses/parser.ts +56 -2
  35. package/src/responses/state.ts +162 -5
  36. package/src/router.ts +10 -3
  37. package/src/routing/compatibility/behavior.ts +3 -3
  38. package/src/routing/profile.ts +1 -1
  39. package/src/server/index.ts +5 -0
  40. package/src/server/management/shared.ts +3 -1
  41. package/src/server/responses/collaboration.ts +34 -9
  42. package/src/server/responses/core.ts +13 -4
  43. package/src/server/responses/input-admission.ts +7 -2
  44. package/src/service-manager-probe.ts +99 -0
  45. package/src/service.ts +86 -6
  46. package/src/tray/windows.ts +25 -5
  47. package/src/types/accounts.ts +37 -0
  48. package/src/types/config.ts +818 -0
  49. package/src/types/provider.ts +521 -0
  50. package/src/types/request.ts +358 -0
  51. package/src/types/tools.ts +131 -0
  52. package/src/types/wire.ts +80 -0
  53. package/src/types.ts +103 -1883
  54. package/src/usage/cost.ts +37 -1
  55. package/src/web-search/loop.ts +11 -4
@@ -17,6 +17,7 @@ import {
17
17
  cursorModelReasoningEfforts,
18
18
  } from "../adapters/cursor/discovery";
19
19
  import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts";
20
+ import { isCanonicalOpenRouterTarget } from "./openrouter-routing";
20
21
 
21
22
  export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
22
23
  export type MetadataModelIdNormalize = "case-insensitive";
@@ -217,6 +218,11 @@ export interface ProviderRegistryEntry {
217
218
  supportsServiceTier?: boolean;
218
219
  /** Registry default for exact model service-tier capability; explicit config keys win. */
219
220
  modelSupportsServiceTier?: Record<string, boolean>;
221
+ /**
222
+ * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence
223
+ * without changing provider ownership, routing, authentication, or config validation.
224
+ */
225
+ modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean;
220
226
  /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */
221
227
  preserveResponsesReasoningContent?: boolean;
222
228
  /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */
@@ -1349,7 +1355,33 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1349
1355
  autoToolChoiceOnlyModels: ["kimi-k2.7-code"],
1350
1356
  preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS,
1351
1357
  },
1352
- { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter", models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], modelContextWindows: { "anthropic/claude-sonnet-5": 1_000_000, ...OPENROUTER_GPT56_CONTEXT_WINDOWS } },
1358
+ {
1359
+ id: "openrouter",
1360
+ label: "OpenRouter",
1361
+ adapter: "openai-chat",
1362
+ baseUrl: "https://openrouter.ai/api/v1",
1363
+ authKind: "key",
1364
+ featured: true,
1365
+ dashboardUrl: "https://openrouter.ai/keys",
1366
+ jawcodeBundle: "openrouter",
1367
+ models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS],
1368
+ modelContextWindows: {
1369
+ "anthropic/claude-sonnet-5": 1_000_000,
1370
+ ...OPENROUTER_GPT56_CONTEXT_WINDOWS,
1371
+ },
1372
+ // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the
1373
+ // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts
1374
+ // belong only to the canonical destination; a same-named custom gateway is unknown to us.
1375
+ modelServiceTierCapabilityBaseUrlGuard: isCanonicalOpenRouterTarget,
1376
+ modelSupportsServiceTier: {
1377
+ "openai/gpt-5.6-sol": true,
1378
+ "openai/gpt-5.6-terra": true,
1379
+ "openai/gpt-5.6-luna": true,
1380
+ },
1381
+ // Deliberately no OpenRouter route pin: it bills the endpoint actually used and reports the
1382
+ // actual service_tier. B0 confirmation therefore owns downgrade safety. Forcing `only` plus
1383
+ // `allow_fallbacks:false` would turn a graceful priority-capacity fallback into a hard failure.
1384
+ },
1353
1385
  {
1354
1386
  // Primary sources checked 2026-08-02:
1355
1387
  // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly
@@ -2470,6 +2502,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2470
2502
  // The gateway validates the ladder strictly and rejects anything above `high`.
2471
2503
  reasoningEfforts: ["low", "medium", "high"],
2472
2504
  reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" },
2505
+ // Live token-plan verification (#1927): the Pro route rejects image input while
2506
+ // mimo-v2.5 accepts it natively. Keep this provider-scoped so a hand-rolled
2507
+ // provider with the same id but another destination does not inherit the claim.
2508
+ noVisionModels: ["mimo-v2.5-pro"],
2473
2509
  // A user may already have hand-rolled a provider under this id against a different host;
2474
2510
  // without this, routedProviderConfig() would canonicalize their base URL onto ours and send
2475
2511
  // their key somewhere they did not choose.
@@ -2555,6 +2591,15 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un
2555
2591
  return PROVIDER_REGISTRY.find(entry => entry.id === id);
2556
2592
  }
2557
2593
 
2594
+ /** Whether this registry row's per-model service-tier evidence applies to one configured target. */
2595
+ export function registryModelServiceTierCapabilityApplies(
2596
+ entry: Pick<ProviderRegistryEntry, "modelServiceTierCapabilityBaseUrlGuard">,
2597
+ provider: Pick<OcxProviderConfig, "baseUrl">,
2598
+ ): boolean {
2599
+ const guard = entry.modelServiceTierCapabilityBaseUrlGuard;
2600
+ return guard === undefined || guard(provider.baseUrl);
2601
+ }
2602
+
2558
2603
  function normalizedProviderEndpoint(value: string): string {
2559
2604
  const trimmed = value.trim();
2560
2605
  try {
@@ -4,6 +4,7 @@ import { isCanonicalOpenAiForwardProvider } from "./openai-tiers";
4
4
  import {
5
5
  getProviderRegistryEntry,
6
6
  providerMatchesRegistryTransport,
7
+ registryModelServiceTierCapabilityApplies,
7
8
  type InboundWire,
8
9
  type ModelWireDefault,
9
10
  } from "./registry";
@@ -57,8 +58,14 @@ function buildFastPolicyAuthority(
57
58
  providerName: string,
58
59
  provider: ServiceTierCapabilityProvider,
59
60
  registryTransportMatch: boolean,
61
+ capabilityProvider: ServiceTierCapabilityProvider = provider,
60
62
  ): FastPolicyAuthority {
61
63
  const registry = registryTransportMatch ? getProviderRegistryEntry(providerName) : undefined;
64
+ const registryModelCapabilities = registry
65
+ && registryModelServiceTierCapabilityApplies(registry, capabilityProvider)
66
+ ? registry.modelSupportsServiceTier
67
+ : undefined;
68
+ const providerCapability = capabilityProvider.supportsServiceTier ?? registry?.supportsServiceTier;
62
69
  const authority: FastPolicyAuthority = Object.freeze({
63
70
  providerAdapter: provider.adapter,
64
71
  fastWireDeclaration: cloneFastWire(
@@ -72,8 +79,11 @@ function buildFastPolicyAuthority(
72
79
  provider.apiKeyTransport,
73
80
  ),
74
81
  capability: Object.freeze({
75
- ...(provider.supportsServiceTier !== undefined ? { provider: provider.supportsServiceTier } : {}),
76
- models: Object.freeze({ ...(provider.modelSupportsServiceTier ?? {}) }),
82
+ ...(providerCapability !== undefined ? { provider: providerCapability } : {}),
83
+ models: Object.freeze({
84
+ ...(registryModelCapabilities ?? {}),
85
+ ...(capabilityProvider.modelSupportsServiceTier ?? {}),
86
+ }),
77
87
  ...(provider.chatServiceTier !== undefined ? { chatServiceTier: provider.chatServiceTier } : {}),
78
88
  }),
79
89
  modelAdapters: Object.freeze({ ...(provider.modelAdapters ?? {}) }),
@@ -87,8 +97,14 @@ export function captureFastPolicyAuthority(
87
97
  providerName: string,
88
98
  provider: ServiceTierCapabilityProvider,
89
99
  registryTransportMatch: boolean,
100
+ capabilityProvider: ServiceTierCapabilityProvider = provider,
90
101
  ): FastPolicyAuthority {
91
- const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch);
102
+ const authority = buildFastPolicyAuthority(
103
+ providerName,
104
+ provider,
105
+ registryTransportMatch,
106
+ capabilityProvider,
107
+ );
92
108
  if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority);
93
109
  return authority;
94
110
  }
@@ -106,12 +122,13 @@ export function captureServiceTierAdapterAuthority(
106
122
  function authorityForProvider(
107
123
  provider: ServiceTierCapabilityProvider,
108
124
  providerName?: string,
125
+ capabilityProvider?: ServiceTierCapabilityProvider,
109
126
  ): FastPolicyAuthority {
110
127
  // Preserve the legacy no-name short circuit: serviceTierSupportForModel() used the
111
128
  // provider adapter directly when no provider identity was available, so no configured
112
129
  // override, hard pin, or registry default may participate on this path in A1.
113
130
  if (providerName === undefined) {
114
- const authority = buildFastPolicyAuthority("", provider, false);
131
+ const authority = buildFastPolicyAuthority("", provider, false, capabilityProvider ?? provider);
115
132
  return Object.freeze({
116
133
  ...authority,
117
134
  modelAdapters: Object.freeze({}),
@@ -119,12 +136,17 @@ function authorityForProvider(
119
136
  registryWireDefaults: Object.freeze({}),
120
137
  });
121
138
  }
122
- const captured = Object.isFrozen(provider)
139
+ const captured = capabilityProvider === undefined && Object.isFrozen(provider)
123
140
  ? capturedFastPolicyAuthorities.get(provider)
124
141
  : undefined;
125
142
  if (captured) return captured;
126
143
  const registryTransportMatch = providerMatchesRegistryTransport(providerName, provider);
127
- const authority = buildFastPolicyAuthority(providerName, provider, registryTransportMatch);
144
+ const authority = buildFastPolicyAuthority(
145
+ providerName,
146
+ provider,
147
+ registryTransportMatch,
148
+ capabilityProvider ?? provider,
149
+ );
128
150
  // Frozen provider snapshots cannot drift, so repeated catalog/runtime projections may safely
129
151
  // reuse the registry lookup and detached declaration maps. Mutable configs still rebuild.
130
152
  if (Object.isFrozen(provider)) capturedFastPolicyAuthorities.set(provider, authority);
@@ -137,8 +159,13 @@ export function fastPolicyForModel(
137
159
  modelId: string,
138
160
  providerName?: string,
139
161
  inbound: InboundWire = "responses",
162
+ capabilityProvider?: ServiceTierCapabilityProvider,
140
163
  ): ResolvedFastPolicy {
141
- return resolveFastPolicy(authorityForProvider(provider, providerName), modelId, inbound);
164
+ return resolveFastPolicy(
165
+ authorityForProvider(provider, providerName, capabilityProvider),
166
+ modelId,
167
+ inbound,
168
+ );
142
169
  }
143
170
 
144
171
  /**
@@ -11,7 +11,7 @@ import type {
11
11
  OcxToolCall,
12
12
  OcxReasoningReplayScopeRef,
13
13
  } from "../types";
14
- import { namespacedToolName } from "../types";
14
+ import { namespacedToolName, toolChoiceCandidates } from "../types";
15
15
  import { responsesRequestSchema } from "./schema";
16
16
  import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
17
17
  import { lookupReplayThoughtSignature } from "./thought-signature-replay";
@@ -203,7 +203,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
203
203
  const ns = typeof t.name === "string" && !builtinFunctions ? t.name : undefined;
204
204
  for (const inner of t.tools as unknown[]) {
205
205
  if (isObj(inner) && inner.type === "function" && typeof inner.name === "string") pushFn(inner, ns);
206
- else if (builtinFunctions && isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner);
206
+ else if (isObj(inner) && inner.type === "custom" && typeof inner.name === "string") pushCustom(inner, ns);
207
207
  }
208
208
  }
209
209
  else if (t.type === "custom" && typeof t.name === "string") {
@@ -328,6 +328,35 @@ function attachPendingReasoningToCallOwner(
328
328
 
329
329
  const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max"]);
330
330
 
331
+ /**
332
+ * Namespace a custom tool was declared under, by its bare name.
333
+ *
334
+ * A `custom_tool_call` echoed back by the client carries only the bare name — the bridge
335
+ * emits `{"type":"custom_tool_call","name":"exec"}` even when the tool was declared as
336
+ * `mcp__functions__exec`. Without this lookup the namespace is lost on the return trip,
337
+ * and the adapters replay history through `namespacedToolName(namespace, name)`, which
338
+ * then produces a bare `exec` the provider may not have. Ordinary `function_call` items
339
+ * do not need this: they carry `namespace` on the wire.
340
+ */
341
+ function customToolNamespaces(tools: unknown): Map<string, string> {
342
+ const out = new Map<string, string>();
343
+ if (!Array.isArray(tools)) return out;
344
+ for (const spec of tools) {
345
+ if (!isObj(spec) || spec.type !== "namespace" || !Array.isArray(spec.tools)) continue;
346
+ const namespace = typeof spec.name === "string" ? spec.name : undefined;
347
+ // Codex 0.147 groups ordinary client tools under the reserved `functions` namespace and
348
+ // buildTools deliberately flattens those without a namespace. Mirror that here, or the
349
+ // reconstruction would invent a namespace the request never advertised.
350
+ if (!namespace || namespace === "functions") continue;
351
+ for (const inner of spec.tools) {
352
+ if (!isObj(inner) || inner.type !== "custom" || typeof inner.name !== "string") continue;
353
+ // Ambiguous bare names are already rejected upstream, so first declaration wins.
354
+ if (!out.has(inner.name)) out.set(inner.name, namespace);
355
+ }
356
+ }
357
+ return out;
358
+ }
359
+
331
360
  export function parseRequest(
332
361
  body: unknown,
333
362
  parseOptions?: { replayCacheScope?: OcxReasoningReplayScopeRef },
@@ -341,6 +370,9 @@ export function parseRequest(
341
370
  const data = parsed.data;
342
371
  const now = Date.now();
343
372
  const messages: OcxMessage[] = [];
373
+ // Built before the item loop: a custom_tool_call echoed back in `input` needs the
374
+ // namespace from the request's own tool catalog to survive the round trip.
375
+ const customToolNamespacesByName = customToolNamespaces(data.tools);
344
376
  const systemPrompt: string[] = [];
345
377
  // Responses reasoning siblings belong to the following assistant, including across call items.
346
378
  // Keep them off the message list until that assistant arrives; turn boundaries clear the array.
@@ -574,10 +606,15 @@ export function parseRequest(
574
606
  if (effectiveType === "custom_tool_call") {
575
607
  const call = item as { id?: string; call_id: string; name: string; input: string };
576
608
  const remembered = typeof call.call_id === "string" ? replayThoughtSignatureMetadata(call.call_id, replayCacheScope) : undefined;
609
+ // Reconstruct the namespace the request declared this tool under. The wire item
610
+ // carries only the bare name, so without this the round trip loses it and adapters
611
+ // replay the call as an unnamespaced tool the provider may not expose.
612
+ const customNamespace = customToolNamespacesByName.get(call.name);
577
613
  const toolCall: OcxToolCall = {
578
614
  type: "toolCall", id: call.call_id, name: call.name,
579
615
  arguments: { input: call.input ?? "" },
580
616
  customWireName: call.name,
617
+ ...(customNamespace ? { namespace: customNamespace } : {}),
581
618
  ...(remembered ? { providerMetadata: remembered } : {}),
582
619
  };
583
620
  assistantHolderWithReasoning().content.push(toolCall);
@@ -691,6 +728,15 @@ export function parseRequest(
691
728
  const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? [];
692
729
  const loadedTools = buildTools(loadedToolSpecs) ?? [];
693
730
  const loadedToolNames = new Set(loadedTools.map(t => namespacedToolName(t.namespace, t.name)));
731
+ const wireOwners = new Map<string, OcxTool>();
732
+ for (const tool of [...declaredTools, ...loadedTools]) {
733
+ const wireName = namespacedToolName(tool.namespace, tool.name);
734
+ const previous = wireOwners.get(wireName);
735
+ if (previous && (previous.namespace !== tool.namespace || previous.name !== tool.name || previous.freeform !== tool.freeform || previous.toolSearch !== tool.toolSearch)) {
736
+ throw new Error(`ambiguous tool catalog: multiple logical tools map to wire name ${wireName}`);
737
+ }
738
+ wireOwners.set(wireName, tool);
739
+ }
694
740
  const seenTools = new Set<string>();
695
741
  const mergedTools = [...declaredTools, ...loadedTools]
696
742
  .filter(t => {
@@ -716,6 +762,14 @@ export function parseRequest(
716
762
  options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop;
717
763
  }
718
764
  const tc = mapToolChoice(data.tool_choice);
765
+ if (tc && typeof tc === "object") {
766
+ const selectors = "allowedTools" in tc ? tc.allowedTools : [tc.name];
767
+ for (const selector of selectors) {
768
+ if (toolChoiceCandidates(mergedTools, selector).length > 1) {
769
+ throw new Error(`ambiguous tool_choice name: ${selector}`);
770
+ }
771
+ }
772
+ }
719
773
  if (tc !== undefined) options.toolChoice = tc;
720
774
  if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
721
775
  // Upstream codex-rs converts "ultra" to "max" at the inference boundary (core/src/client.rs
@@ -1,4 +1,5 @@
1
1
  import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, rmSync, statSync, unlinkSync } from "node:fs";
2
+ import { uptime } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { atomicWriteFileAsync, getConfigDir, resolveWriteTarget } from "../config";
4
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
@@ -31,6 +32,17 @@ const SNAPSHOT_FILE_MAX_BYTES = 32 * 1024 * 1024;
31
32
  const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
32
33
  const STALE_TEMP_MAX_ENTRIES = 4_096;
33
34
  const STALE_TEMP_MAX_CLEANUPS = 512;
35
+ /** Absorbs `os.uptime()` granularity only. It is deliberately NOT the safety margin:
36
+ * the unconditional 15-minute grace above is (see the boot floor in the scan loop). */
37
+ const BOOT_FLOOR_SKEW_MS = 60 * 1_000;
38
+ /** Per-tick budget for the periodic reclaim. Smaller than the startup budget because the
39
+ * periodic pass runs synchronously on the serving process's event loop every 60 s. */
40
+ const PERIODIC_TEMP_MAX_ENTRIES = 512;
41
+ const PERIODIC_TEMP_MAX_CLEANUPS = 64;
42
+ /** Wall-clock ceiling for one periodic scan. An entry cap bounds syscalls, not time: on a
43
+ * network-mounted config dir each `lstat` can cost 10-20 ms, which would stall in-flight
44
+ * streams. Reclaim is idempotent, so a truncated tick simply resumes on the next one. */
45
+ const PERIODIC_TEMP_SCAN_DEADLINE_MS = 25;
34
46
  const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
35
47
  const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
36
48
 
@@ -494,19 +506,37 @@ export interface ResponseStateTempRecoveryResult {
494
506
  removed: number;
495
507
  failed: number;
496
508
  bytesRemoved: number;
509
+ /** Entries that passed EVERY gate and would be reclaimed. In a dry run nothing is
510
+ * unlinked, so this is the only honest count to show an operator: `matched` is
511
+ * incremented before the file-type, age, boot-floor, and liveness gates. */
512
+ eligible: number;
513
+ /** Total size of the `eligible` entries. */
514
+ eligibleBytes: number;
515
+ /** The scan stopped on a budget (entry cap, cleanup cap, or deadline) rather than reaching
516
+ * the end of the directory, so the counts below describe a prefix of the backlog and not
517
+ * the backlog. `eligible > removed + failed` cannot express this: outside a dry run every
518
+ * eligible entry is unlinked or failed on the same iteration, so the two are always equal
519
+ * and a comparison between them is dead code. */
520
+ truncated: boolean;
497
521
  }
498
522
 
499
523
  interface ResponseStateTempRecoveryIO {
500
524
  now: () => number;
525
+ /** Approximate epoch ms of the current boot; see the boot floor in the scan loop. */
526
+ bootTime: () => number;
501
527
  list: (dir: string) => Iterable<string>;
502
528
  inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
503
529
  isProcessAlive: (pid: number) => boolean;
504
530
  unlink: (path: string) => void;
505
531
  }
506
532
 
507
- type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
533
+ export type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
508
534
  maxEntries?: number;
509
535
  maxCleanups?: number;
536
+ /** Wall-clock ceiling for the scan, or null/undefined for no deadline (startup path). */
537
+ deadlineMs?: number | null;
538
+ /** Report only: apply every gate, count what would be reclaimed, unlink nothing. */
539
+ dryRun?: boolean;
510
540
  };
511
541
 
512
542
  function processIsAlive(pid: number): boolean {
@@ -523,6 +553,7 @@ function processIsAlive(pid: number): boolean {
523
553
 
524
554
  const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
525
555
  now: Date.now,
556
+ bootTime: () => Date.now() - uptime() * 1_000,
526
557
  list: function* list(dir) {
527
558
  const handle = opendirSync(dir);
528
559
  try {
@@ -549,26 +580,56 @@ export function recoverStaleResponseStateTemps(
549
580
  dir = getConfigDir(),
550
581
  options: ResponseStateTempRecoveryOptions = {},
551
582
  ): ResponseStateTempRecoveryResult {
552
- const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options;
583
+ const {
584
+ maxEntries = STALE_TEMP_MAX_ENTRIES,
585
+ maxCleanups = STALE_TEMP_MAX_CLEANUPS,
586
+ deadlineMs = null,
587
+ dryRun = false,
588
+ ...overrides
589
+ } = options;
553
590
  const io = { ...responseStateTempRecoveryIO, ...overrides };
554
591
  const result: ResponseStateTempRecoveryResult = {
555
592
  matched: 0,
556
593
  removed: 0,
557
594
  failed: 0,
558
595
  bytesRemoved: 0,
596
+ eligible: 0,
597
+ eligibleBytes: 0,
598
+ truncated: false,
559
599
  };
600
+ const startedAt = io.now();
601
+ // One probe per scan, not one per entry. A non-finite or future-dated boot is anomalous, and
602
+ // clamping it to "now" would be the WORST response: the floor would then retire the liveness
603
+ // probe for every file older than the skew, which is every file past the grace. Disable it
604
+ // instead -- an absent floor only costs a missed reclaim, never a wrong one.
605
+ const rawBoot = io.bootTime();
606
+ const bootMs = Number.isFinite(rawBoot) && rawBoot <= startedAt ? rawBoot : Number.NEGATIVE_INFINITY;
560
607
  let names: Iterable<string>;
561
608
  try { names = io.list(dir); } catch { return result; }
562
609
  let iterator: Iterator<string>;
563
610
  try { iterator = names[Symbol.iterator](); } catch { return result; }
564
611
  let scanned = 0;
612
+ // Every early exit runs through this. The production `list` is a generator that closes its
613
+ // directory handle in a `finally`, and a `finally` does NOT run when the consumer simply
614
+ // stops calling `next()` -- only `return()` resumes the generator to completion. Breaking
615
+ // out of the loop directly therefore leaked one directory handle per truncated scan, and the
616
+ // periodic reclaim truncates on purpose (entry cap, cleanup cap, deadline), so on a slow
617
+ // filesystem that is a leak per tick, forever.
618
+ const stopScan = (): ResponseStateTempRecoveryResult => {
619
+ try { iterator.return?.(); } catch { /* closing is best-effort; never fail a reclaim on it */ }
620
+ return result;
621
+ };
565
622
  for (;;) {
566
623
  let next: IteratorResult<string>;
567
624
  try { next = iterator.next(); } catch { return result; }
568
625
  if (next.done) break;
569
626
  const name = next.value;
570
627
  scanned += 1;
571
- if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
628
+ // A dry run performs no cleanups, so bounding it by the cleanup budget would truncate
629
+ // the very report an operator uses to size the problem.
630
+ if (scanned > maxEntries) { result.truncated = true; return stopScan(); }
631
+ if (!dryRun && result.removed + result.failed >= maxCleanups) { result.truncated = true; return stopScan(); }
632
+ if (deadlineMs !== null && io.now() - startedAt > deadlineMs) { result.truncated = true; return stopScan(); }
572
633
  const match = RESPONSE_STATE_TEMP_NAME.exec(name);
573
634
  if (!match) continue;
574
635
  result.matched += 1;
@@ -579,13 +640,34 @@ export function recoverStaleResponseStateTemps(
579
640
  let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
580
641
  try { file = io.inspect(path); } catch { continue; }
581
642
  if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
582
- if (pid === process.pid || io.isProcessAlive(pid)) continue;
643
+ // Boot floor. After a reboot the original writer's pid is routinely reused, which makes
644
+ // the liveness skip PERMANENT: the 15-minute grace above is a lower bound and never
645
+ // expires it, so the file is skipped on every future pass forever. A temp older than
646
+ // this boot cannot be owned by the pid we would probe, so the probe is vacuous and we
647
+ // retire it. This does NOT claim the file is provably dead: under a shared-volume
648
+ // container, suspend-excluding uptime, or a network config dir the computed boot can
649
+ // land after the real one. The unconditional 15-minute grace above remains the safety
650
+ // floor, and this process's own temps are never touched.
651
+ const predatesBoot = file.mtimeMs < bootMs - BOOT_FLOOR_SKEW_MS;
652
+ if (pid === process.pid) continue;
653
+ if (!predatesBoot && io.isProcessAlive(pid)) continue;
654
+
655
+ result.eligible += 1;
656
+ result.eligibleBytes += file.size;
657
+ if (dryRun) continue;
583
658
 
584
659
  try {
585
660
  io.unlink(path);
586
661
  result.removed += 1;
587
662
  result.bytesRemoved += file.size;
588
- } catch {
663
+ } catch (error) {
664
+ // Another proxy sharing this config dir may have won the race. A file that is already
665
+ // gone is reclaimed, not a failure -- reporting it as one would surface "in use or
666
+ // locked" to an operator for a file nobody holds.
667
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
668
+ result.removed += 1;
669
+ continue;
670
+ }
589
671
  // Locked files remain for a later startup. Do not truncate by path: a same-user
590
672
  // replacement could turn that fallback into an arbitrary symlink-target write.
591
673
  result.failed += 1;
@@ -594,6 +676,23 @@ export function recoverStaleResponseStateTemps(
594
676
  return result;
595
677
  }
596
678
 
679
+ /**
680
+ * Literal config dir plus the snapshot's resolved dir. Atomic writes place their temp beside
681
+ * the RESOLVED target, so a symlinked snapshot (dotfiles-managed config dir) strands temps in
682
+ * the link's real directory where a scan of the literal dir would never see them. The two
683
+ * collapse to one when nothing is symlinked.
684
+ */
685
+ function responseStateSweepDirectories(): Set<string> {
686
+ const path = snapshotPath();
687
+ let resolvedDir = dirname(path);
688
+ try {
689
+ resolvedDir = dirname(resolveWriteTarget(path));
690
+ } catch {
691
+ /* unresolvable link: sweep the literal dir only */
692
+ }
693
+ return new Set([dirname(path), resolvedDir]);
694
+ }
695
+
597
696
  /**
598
697
  * Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
599
698
  * dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
@@ -898,6 +997,64 @@ export function sweepExpiredResponseStates(at = now()): number {
898
997
  return removed;
899
998
  }
900
999
 
1000
+ /**
1001
+ * Periodic disk reclaim for abandoned atomic-write temps.
1002
+ *
1003
+ * `ensureLoaded` sweeps once per process, at load, BEFORE that process writes anything:
1004
+ * every `schedulePersist` site is downstream of it. So a process that abandons a temp has
1005
+ * already had its only look, the 15-minute grace hides the temp its predecessor's crash
1006
+ * just produced, and `maxCleanups` caps a single pass below a large backlog. A restart
1007
+ * loop therefore accumulates monotonically. Repeating the reclaim on a timer fixes all
1008
+ * three: the grace expires into a later tick and the per-pass cap becomes a per-tick rate.
1009
+ *
1010
+ * Registered on the sweeper's LIVENESS tick, not the TTL tick: `sweepExpiredOnWrite` puts
1011
+ * `sweepExpired` on hot write paths, and a directory scan does not belong there.
1012
+ */
1013
+ export function reclaimAbandonedResponseStateTemps(
1014
+ options: ResponseStateTempRecoveryOptions = {},
1015
+ ): ResponseStateTempRecoveryResult {
1016
+ const total: ResponseStateTempRecoveryResult = {
1017
+ matched: 0, removed: 0, failed: 0, bytesRemoved: 0, eligible: 0, eligibleBytes: 0, truncated: false,
1018
+ };
1019
+ // The try encloses responseStateSweepDirectories() deliberately: recoverStaleResponseStateTemps
1020
+ // already swallows its own enumeration failures, so a catch around only that call would be
1021
+ // unreachable. snapshotPath()/getConfigDir() are the paths that can genuinely throw.
1022
+ try {
1023
+ for (const dir of responseStateSweepDirectories()) {
1024
+ const result = recoverStaleResponseStateTemps(dir, options);
1025
+ total.matched += result.matched;
1026
+ total.removed += result.removed;
1027
+ total.failed += result.failed;
1028
+ total.bytesRemoved += result.bytesRemoved;
1029
+ total.eligible += result.eligible;
1030
+ total.eligibleBytes += result.eligibleBytes;
1031
+ // Truncation anywhere makes the whole total a prefix.
1032
+ total.truncated ||= result.truncated;
1033
+ }
1034
+ } catch {
1035
+ /* best-effort: disk reclaim must never destabilize the caller */
1036
+ }
1037
+ return total;
1038
+ }
1039
+
1040
+ /**
1041
+ * Report-only counterpart for `ocx doctor`: applies every selection gate and unlinks
1042
+ * nothing. It runs the SAME predicate as the reclaim, so the report and the subsequent
1043
+ * removal cannot disagree about which files are reclaimable.
1044
+ */
1045
+ export function inspectAbandonedResponseStateTemps(): ResponseStateTempRecoveryResult {
1046
+ return reclaimAbandonedResponseStateTemps({ dryRun: true });
1047
+ }
1048
+
1049
+ /** Sweeper adapter: narrows the reclaim to the `() => number` the liveness tick expects. */
1050
+ export function sweepAbandonedResponseStateTemps(): number {
1051
+ return reclaimAbandonedResponseStateTemps({
1052
+ maxEntries: PERIODIC_TEMP_MAX_ENTRIES,
1053
+ maxCleanups: PERIODIC_TEMP_MAX_CLEANUPS,
1054
+ deadlineMs: PERIODIC_TEMP_SCAN_DEADLINE_MS,
1055
+ }).removed;
1056
+ }
1057
+
901
1058
  export function responseContinuationRetainedStoreSnapshot(): RetainedStoreSnapshot {
902
1059
  return {
903
1060
  count: states.size,
package/src/router.ts CHANGED
@@ -8,10 +8,15 @@ import {
8
8
  type ComboPick,
9
9
  } from "./combos";
10
10
  import type { NormalizedComboConfig } from "./combos/types";
11
- import { hasOwnProvider, resolveEnvValue } from "./config";
11
+ import { hasOwnProvider } from "./config/provider-name";
12
+ import { resolveEnvValue } from "./config";
12
13
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
13
14
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
14
- import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
15
+ import {
16
+ PROVIDER_REGISTRY,
17
+ providerCodexAccountMode,
18
+ registryModelServiceTierCapabilityApplies,
19
+ } from "./providers/registry";
15
20
  import { applyDirectReasoningEffortContracts, hasLegacyClinePassReasoningEfforts } from "./providers/derive";
16
21
  import { cloneFastWire } from "./providers/fastwire";
17
22
  import {
@@ -296,7 +301,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
296
301
  : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens);
297
302
  const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens);
298
303
  const modelSupportsServiceTier = mergeRecordFill(
299
- registryEntry.modelSupportsServiceTier,
304
+ registryModelServiceTierCapabilityApplies(registryEntry, provider)
305
+ ? registryEntry.modelSupportsServiceTier
306
+ : undefined,
300
307
  provider.modelSupportsServiceTier,
301
308
  );
302
309
  const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels);
@@ -1,7 +1,7 @@
1
1
  import { modelInList } from "../../types";
2
2
  import type { OcxConfig, OcxProviderConfig } from "../../types";
3
3
  import { PROVIDER_REGISTRY } from "../../providers/registry";
4
- import { fastPolicyForModel, serviceTierSupportForModel } from "../../providers/service-tier";
4
+ import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier";
5
5
  import { resolveProviderAuthTransport } from "../../providers/fastwire";
6
6
  import { localFingerprint } from "../../lab/digest";
7
7
  import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
@@ -103,7 +103,7 @@ export function resolveProductionBehaviorValues(
103
103
  const project = typeof effective.project === "string" && effective.project ? effective.project : null;
104
104
  const location = typeof effective.location === "string" && effective.location ? effective.location : null;
105
105
  const nativeLocalExec = effective.nativeLocalExec === "on" || effective.unsafeAllowNativeLocalExec === true;
106
- const fastPolicy = fastPolicyForModel(effective, modelId, providerName);
106
+ const fastPolicy = fastPolicyForModel(effective, modelId, providerName, "responses", provider);
107
107
 
108
108
  const values: LabBehaviorValues = {
109
109
  "wire.adapter": behaviorRow("provider_config", adapter),
@@ -122,7 +122,7 @@ export function resolveProductionBehaviorValues(
122
122
  "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true),
123
123
  "responses.serviceTier": behaviorRow(
124
124
  "provider_config",
125
- serviceTierSupportForModel(effective, modelId, providerName) ?? null,
125
+ serviceTierSupportFromPolicy(fastPolicy) ?? null,
126
126
  ),
127
127
  "responses.fastWireKind": behaviorRow(
128
128
  "provider_config",
@@ -13,7 +13,7 @@ import type {
13
13
  } from "../types";
14
14
  import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
15
15
  import { listComboIds, resolveComboId } from "../combos";
16
- import { hasOwnProvider } from "../config";
16
+ import { hasOwnProvider } from "../config/provider-name";
17
17
  import { MAX_COMPATIBILITY_REQUIRED_SUITES } from "./compatibility/types";
18
18
  import { POLICY_NAMESPACE } from "./profile-namespace";
19
19
 
@@ -705,6 +705,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
705
705
  ? startNativeMainStartupLifecycle(deps.nativeMainStartup)
706
706
  : blockNativeMainStartupForUnownedServiceHome(
707
707
  nativeOwnership.ownership === "foreign" ? "foreign-ownership" : "ownership-unknown",
708
+ // #2108: an `unknown` verdict means the probe could not answer, not that this host
709
+ // is unownable. Hand the fence a way to re-ask so a host that becomes answerable
710
+ // after boot reopens on its own instead of needing `ocx restart`. A `foreign`
711
+ // verdict ignores this by design — that one is a fact, not a question.
712
+ { reprobe: () => inspectStartupOwnership(deps).ownership },
708
713
  )
709
714
  : {
710
715
  homeId: null,
@@ -86,7 +86,8 @@ export type CostEstimateReason =
86
86
  | "usage_estimated"
87
87
  | "cache_detail_missing"
88
88
  | "expected_price_overlay"
89
- | "provider_cost_overlay";
89
+ | "provider_cost_overlay"
90
+ | "priority_lower_bound";
90
91
 
91
92
  export type CostResult =
92
93
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
@@ -143,6 +144,7 @@ export function costResult(entry: MetricSource): CostResult {
143
144
  ? "expected_price_overlay" as const : undefined,
144
145
  estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user")
145
146
  ? "provider_cost_overlay" as const : undefined,
147
+ estimate.priorityLowerBound ? "priority_lower_bound" as const : undefined,
146
148
  ].filter((reason): reason is CostEstimateReason => reason !== undefined);
147
149
  return { kind: "value", estimate, estimateReasons };
148
150
  }