@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.
- package/gui/dist/assets/{index-RL6b1bTV.js → index-7jlKgmJd.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +14 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/openai-chat.ts +38 -6
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/models.ts +13 -6
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-context.ts +53 -1
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +6 -4
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +73 -45
- package/src/images/loop.ts +11 -4
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/registry.ts +46 -1
- package/src/providers/service-tier.ts +34 -7
- package/src/responses/parser.ts +56 -2
- package/src/responses/state.ts +162 -5
- package/src/router.ts +10 -3
- package/src/routing/compatibility/behavior.ts +3 -3
- package/src/routing/profile.ts +1 -1
- package/src/server/index.ts +5 -0
- package/src/server/management/shared.ts +3 -1
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/core.ts +13 -4
- package/src/server/responses/input-admission.ts +7 -2
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- 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
|
-
{
|
|
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
|
-
...(
|
|
76
|
-
models: Object.freeze({
|
|
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(
|
|
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(
|
|
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(
|
|
164
|
+
return resolveFastPolicy(
|
|
165
|
+
authorityForProvider(provider, providerName, capabilityProvider),
|
|
166
|
+
modelId,
|
|
167
|
+
inbound,
|
|
168
|
+
);
|
|
142
169
|
}
|
|
143
170
|
|
|
144
171
|
/**
|
package/src/responses/parser.ts
CHANGED
|
@@ -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 (
|
|
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
|
package/src/responses/state.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
125
|
+
serviceTierSupportFromPolicy(fastPolicy) ?? null,
|
|
126
126
|
),
|
|
127
127
|
"responses.fastWireKind": behaviorRow(
|
|
128
128
|
"provider_config",
|
package/src/routing/profile.ts
CHANGED
|
@@ -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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
}
|