@bitkyc08/opencodex 2.25.0 → 2.26.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/bin/ocx.mjs +59 -7
- package/gui/dist/assets/index-RL6b1bTV.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/base.ts +18 -0
- package/src/adapters/client-fingerprint.ts +0 -2
- package/src/adapters/cursor/http1-bidi.ts +361 -0
- package/src/adapters/cursor/live-models.ts +117 -30
- package/src/adapters/cursor/live-transport.ts +137 -56
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +0 -1
- package/src/adapters/cursor/protobuf-request.ts +89 -21
- package/src/adapters/cursor/tool-definitions.ts +18 -7
- package/src/adapters/cursor/tool-result-normalize.ts +92 -0
- package/src/adapters/cursor/transport.ts +7 -0
- package/src/adapters/cursor.ts +2 -0
- package/src/adapters/google-http.ts +38 -10
- package/src/adapters/google.ts +23 -3
- package/src/adapters/openai-chat.ts +38 -13
- package/src/adapters/openai-responses.ts +129 -9
- package/src/adapters/registry.ts +30 -1
- package/src/bridge.ts +50 -12
- package/src/chat/inbound.ts +1 -0
- package/src/claude/agents-inject.ts +3 -2
- package/src/cli/dispatch.ts +19 -6
- package/src/cli/help.ts +2 -1
- package/src/cli/integrations.ts +35 -0
- package/src/cli/minimax.ts +8 -2
- package/src/cli/registry.ts +13 -2
- package/src/clients/config-export.ts +120 -1
- package/src/codex/account-store.ts +17 -1
- package/src/codex/auth-api.ts +51 -17
- package/src/codex/catalog/effort.ts +8 -5
- package/src/codex/catalog/provider-fetch.ts +39 -27
- package/src/codex/catalog/sync.ts +30 -8
- package/src/codex/inject.ts +39 -13
- package/src/codex/main-account.ts +29 -1
- package/src/codex/plan-from-token.ts +140 -0
- package/src/codex/plan.ts +25 -0
- package/src/codex/prompt-journal.ts +6 -2
- package/src/codex/quota-rejection.ts +21 -7
- package/src/codex/refresh.ts +4 -2
- package/src/codex/sync.ts +106 -3
- package/src/codex/warmup.ts +187 -81
- package/src/config.ts +85 -46
- package/src/generated/compatibility-version.json +127 -83
- package/src/grok/inject.ts +1 -1
- package/src/images/loop.ts +1 -0
- package/src/integrations/registry.ts +7 -0
- package/src/lab/automation/config-persistence.ts +2 -2
- package/src/lab/automation/persistence.ts +2 -2
- package/src/lab/ledger/purge.ts +2 -2
- package/src/lab/subject/behavior-fingerprint.ts +2 -2
- package/src/lib/config-ownership.ts +5 -2
- package/src/lib/destination-policy.ts +18 -1
- package/src/lib/provider-outbound.ts +7 -0
- package/src/lib/redact.ts +11 -0
- package/src/lib/tool-argument-integers.ts +50 -6
- package/src/lib/upstream-http-version.ts +57 -0
- package/src/lib/upstream-retry.ts +2 -1
- package/src/lib/windows-atomic-replace.ts +155 -0
- package/src/lib/windows-service-wrappers.ts +72 -0
- package/src/oauth/google-antigravity.ts +2 -2
- package/src/oauth/index.ts +60 -8
- package/src/providers/antigravity-models.ts +58 -4
- package/src/providers/command-code-efforts.ts +36 -9
- package/src/providers/context-cap.ts +9 -0
- package/src/providers/derive.ts +4 -0
- package/src/providers/fastwire.ts +453 -0
- package/src/providers/registry.ts +21 -1
- package/src/providers/service-tier.ts +173 -89
- package/src/responses/parser.ts +25 -16
- package/src/responses/reasoning-replay-cache.ts +30 -0
- package/src/responses/thought-signature-replay.ts +74 -4
- package/src/router.ts +6 -0
- package/src/routing/compatibility/behavior.ts +27 -16
- package/src/server/index.ts +10 -1
- package/src/server/management/oauth-account-routes.ts +10 -2
- package/src/server/management/shared.ts +1 -1
- package/src/server/management/system-routes.ts +15 -0
- package/src/server/request-log.ts +57 -3
- package/src/server/responses/agent-task-recovery.ts +6 -1
- package/src/server/responses/core.ts +209 -40
- package/src/server/responses/fetch-helpers.ts +15 -36
- package/src/server/responses/responses-field-backfill.ts +173 -0
- package/src/server/responses-reasoning-summary-rewrite.ts +171 -0
- package/src/service.ts +38 -36
- package/src/storage/cleanup.ts +2 -2
- package/src/tray/windows.ts +3 -2
- package/src/types.ts +101 -14
- package/src/update/job.ts +9 -18
- package/src/update/transactional-install.d.mts +22 -0
- package/src/update/transactional-install.mjs +259 -0
- package/src/usage/cost.ts +34 -5
- package/src/usage/log.ts +74 -4
- package/src/vision/anthropic-describe.ts +10 -6
- package/src/web-search/anthropic-executor.ts +8 -6
- package/gui/dist/assets/index-DxJ7kXj9.js +0 -102
package/src/types.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface OcxReasoningReplayIdentity {
|
|
|
14
14
|
modelId: string;
|
|
15
15
|
/** Opaque process-local credential identity; never a raw token or API key. */
|
|
16
16
|
credentialIdentity: string;
|
|
17
|
+
/**
|
|
18
|
+
* Salted-HMAC credential identity that survives restarts, for the durable
|
|
19
|
+
* thought-signature store (#1926). Absent when no durable identity could be
|
|
20
|
+
* derived — the durable store then refuses to key the entry (fail closed).
|
|
21
|
+
*/
|
|
22
|
+
credentialDurableIdentity?: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
/**
|
|
@@ -301,6 +307,10 @@ export interface OcxRequestOptions {
|
|
|
301
307
|
reasoning?: string;
|
|
302
308
|
hideThinkingSummary?: boolean;
|
|
303
309
|
serviceTier?: string;
|
|
310
|
+
/** Final outbound tier action, resolved after the provider/model wire is settled. */
|
|
311
|
+
tierDecision?: TierDecision;
|
|
312
|
+
/** Internal B0 observation inputs; adapters combine these with the wire they actually serialize. */
|
|
313
|
+
tierObservation?: TierObservationContext;
|
|
304
314
|
presencePenalty?: number;
|
|
305
315
|
frequencyPenalty?: number;
|
|
306
316
|
/** Responses prompt-cache affinity key. Passthrough preserves it via _rawBody; routed adapters do not consume it unless their upstream wire supports it. */
|
|
@@ -1315,6 +1325,51 @@ export interface ProviderRequestPacingConfig extends RequestPacingRule {
|
|
|
1315
1325
|
models?: Record<string, RequestPacingRule>;
|
|
1316
1326
|
}
|
|
1317
1327
|
|
|
1328
|
+
export interface FastWire {
|
|
1329
|
+
kind: "service-tier" | "anthropic-speed";
|
|
1330
|
+
/** Canonical tier name to upstream wire spelling. */
|
|
1331
|
+
canonicalToWire: Readonly<Record<string, string>>;
|
|
1332
|
+
/** Policy for non-canonical caller-provided tier values. */
|
|
1333
|
+
foreignCallerTiers: "verbatim" | "drop";
|
|
1334
|
+
/** Anthropic speed headers/betas reserved for the later wire implementation. */
|
|
1335
|
+
betas?: readonly string[];
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Durable per-attempt service-tier fact produced at the adapter serialization boundary. */
|
|
1339
|
+
export interface AttemptTierOutcome {
|
|
1340
|
+
canonical?: "priority";
|
|
1341
|
+
wireKind?: FastWire["kind"] | null;
|
|
1342
|
+
wireValue?: string | null;
|
|
1343
|
+
fastOutcome: "not-requested" | "applied" | "downgraded" | "unknown";
|
|
1344
|
+
fastDowngradeReason?: "route-unsupported" | "wire-unavailable" | "response-declined";
|
|
1345
|
+
callerTierDropped?: boolean;
|
|
1346
|
+
callerFastSuppressedByConfig?: boolean;
|
|
1347
|
+
confirmation: "confirmed" | "assumed" | "downgraded" | "unknown";
|
|
1348
|
+
responseServiceTier?: string;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* Request-local observation inputs captured before the final tier action mutates the parsed view.
|
|
1353
|
+
* This is not persisted; the final adapter turns it into AttemptTierOutcome after serialization.
|
|
1354
|
+
*/
|
|
1355
|
+
export interface TierObservationContext {
|
|
1356
|
+
capability: boolean | undefined;
|
|
1357
|
+
eligibility:
|
|
1358
|
+
| "eligible"
|
|
1359
|
+
| "capability-unsupported"
|
|
1360
|
+
| "unclassified"
|
|
1361
|
+
| "wire-unavailable"
|
|
1362
|
+
| "pin-unavailable";
|
|
1363
|
+
fastWire: FastWire | null;
|
|
1364
|
+
demandDecision: "force-fast" | "force-default" | "inherit";
|
|
1365
|
+
callerTier?: string;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export type TierDecision =
|
|
1369
|
+
| { readonly kind: "forward-caller" }
|
|
1370
|
+
| { readonly kind: "drop" }
|
|
1371
|
+
| { readonly kind: "set"; readonly value: string };
|
|
1372
|
+
|
|
1318
1373
|
/**
|
|
1319
1374
|
* One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429
|
|
1320
1375
|
* retries are allowed; OAuth/forward credentials and local runtimes are never replayed.
|
|
@@ -1338,6 +1393,11 @@ export interface OcxProviderConfig {
|
|
|
1338
1393
|
* as before.
|
|
1339
1394
|
*/
|
|
1340
1395
|
modelAdapters?: Record<string, string>;
|
|
1396
|
+
/**
|
|
1397
|
+
* Fast-wire declaration. `null` explicitly disables adapter-derived defaults;
|
|
1398
|
+
* absence derives from the final model adapter.
|
|
1399
|
+
*/
|
|
1400
|
+
fastWire?: FastWire | null;
|
|
1341
1401
|
baseUrl: string;
|
|
1342
1402
|
/**
|
|
1343
1403
|
* Optional relative resource path for key-auth openai-responses requests. Must start with `/`
|
|
@@ -1365,14 +1425,14 @@ export interface OcxProviderConfig {
|
|
|
1365
1425
|
*/
|
|
1366
1426
|
requiresAdjacentResponsesToolResults?: boolean;
|
|
1367
1427
|
/**
|
|
1368
|
-
* Provider fallback for
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1371
|
-
* Tri-state: `true` lets fast mode inject/remove the field
|
|
1372
|
-
* fast mode preserves a caller-supplied value); `false` strips the field and
|
|
1428
|
+
* Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire.
|
|
1429
|
+
* This pure tri-state feeds catalog publication, routing eligibility, compatibility
|
|
1430
|
+
* fingerprints, and proxy-owned canonical Fast injection on both Responses and Chat routes.
|
|
1431
|
+
* Tri-state: `true` lets fast mode inject/remove the canonical field; `false` strips it and
|
|
1373
1432
|
* never injects, because an upstream documented as not supporting the parameter
|
|
1374
|
-
* must not receive it; absent (`undefined`) leaves the provider unclassified —
|
|
1375
|
-
* caller
|
|
1433
|
+
* must not receive it; absent (`undefined`) leaves the provider unclassified — fast mode never
|
|
1434
|
+
* injects or translates, and caller values pass only under the final wire's forwarding permission.
|
|
1435
|
+
* On Chat, that CallerTierForward permission is `chatServiceTier`; Responses retains passthrough.
|
|
1376
1436
|
* An explicit config value always wins over the registry default.
|
|
1377
1437
|
*/
|
|
1378
1438
|
supportsServiceTier?: boolean;
|
|
@@ -1396,8 +1456,10 @@ export interface OcxProviderConfig {
|
|
|
1396
1456
|
* HTTP/2 via TLS ALPN by default; some Cloudflare-fronted SSE endpoints hang on
|
|
1397
1457
|
* HTTP/2 streaming responses (issue #1668). "http1.1" / "h1" forces HTTP/1.1,
|
|
1398
1458
|
* "http2" / "h2" forces HTTP/2. Absent or "auto" keeps Bun's default negotiation
|
|
1399
|
-
* (current behavior unchanged).
|
|
1400
|
-
|
|
1459
|
+
* (current behavior unchanged). Explicit pins require an https: target and fail locally when the
|
|
1460
|
+
* pin cannot be honored. Cursor additionally maps an HTTP/1.1 pin onto its RunSSE + BidiAppend
|
|
1461
|
+
* compatibility transport.
|
|
1462
|
+
*/
|
|
1401
1463
|
upstreamHttpVersion?: UpstreamHttpVersion;
|
|
1402
1464
|
/**
|
|
1403
1465
|
* Google only. When `false`, the AI Studio (direct) path sends Gemini Flash ids
|
|
@@ -1589,13 +1651,16 @@ export interface OcxProviderConfig {
|
|
|
1589
1651
|
*/
|
|
1590
1652
|
promptCacheKey?: boolean;
|
|
1591
1653
|
/**
|
|
1592
|
-
* Opt-in: forward `service_tier` to the upstream `/chat/completions` body.
|
|
1654
|
+
* Opt-in: forward caller `service_tier` values to the upstream `/chat/completions` body.
|
|
1655
|
+
* On a classified route it governs foreign values (for example `flex`), not proxy-owned
|
|
1656
|
+
* canonical Fast after capability validation. On an unclassified route it governs every caller
|
|
1657
|
+
* value, including canonical spellings, because no Fast capability has been validated.
|
|
1593
1658
|
* OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends
|
|
1594
1659
|
* reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a
|
|
1595
1660
|
* caller-supplied `service_tier` would otherwise turn working requests into upstream 400s.
|
|
1596
|
-
* Exact
|
|
1597
|
-
* `supportsServiceTier: false` remains a global denial. Default off;
|
|
1598
|
-
* providers that document this parameter on the chat wire.
|
|
1661
|
+
* Exact-model `true` enables canonical Fast capability but does not grant foreign-tier
|
|
1662
|
+
* forwarding; provider-level `supportsServiceTier: false` remains a global denial. Default off;
|
|
1663
|
+
* only enable for providers that document this parameter on the chat wire.
|
|
1599
1664
|
*/
|
|
1600
1665
|
chatServiceTier?: boolean;
|
|
1601
1666
|
/**
|
|
@@ -1750,6 +1815,19 @@ const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
|
|
|
1750
1815
|
"opencode-go": new Set(["minimax-m2.5", "minimax-m2.7", "minimax-m3"]),
|
|
1751
1816
|
};
|
|
1752
1817
|
|
|
1818
|
+
function anthropicWireModelsForProvider(providerName: string): ReadonlySet<string> | undefined {
|
|
1819
|
+
return Object.hasOwn(ANTHROPIC_WIRE_MODELS, providerName)
|
|
1820
|
+
? ANTHROPIC_WIRE_MODELS[providerName]
|
|
1821
|
+
: undefined;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** Detached provider-local hard-pin table for pure wire-policy resolution. */
|
|
1825
|
+
export function captureWireAdapterHardPins(providerName: string): Readonly<Record<string, string>> {
|
|
1826
|
+
const models = anthropicWireModelsForProvider(providerName);
|
|
1827
|
+
if (!models) return Object.freeze({});
|
|
1828
|
+
return Object.freeze(Object.fromEntries([...models].map(modelId => [modelId, "anthropic"])));
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1753
1831
|
/**
|
|
1754
1832
|
* True when the upstream speaks exactly one wire for this model, so a configured
|
|
1755
1833
|
* override must not apply.
|
|
@@ -1759,7 +1837,7 @@ const ANTHROPIC_WIRE_MODELS: Record<string, ReadonlySet<string>> = {
|
|
|
1759
1837
|
* adapter" would pass on the first pass and then let the override win on the second.
|
|
1760
1838
|
*/
|
|
1761
1839
|
export function isWirePinnedModel(providerName: string, modelId: string): boolean {
|
|
1762
|
-
return
|
|
1840
|
+
return anthropicWireModelsForProvider(providerName)?.has(modelId) ?? false;
|
|
1763
1841
|
}
|
|
1764
1842
|
|
|
1765
1843
|
/** The wire a pinned model must use, or undefined when the model is not pinned. */
|
|
@@ -1773,6 +1851,15 @@ export interface CodexAccount {
|
|
|
1773
1851
|
/** User-owned display label; never participates in routing or identity checks. */
|
|
1774
1852
|
alias?: string;
|
|
1775
1853
|
plan?: string;
|
|
1854
|
+
/**
|
|
1855
|
+
* Provenance of `plan`. WHAM (live quota API) is authoritative; the JWT
|
|
1856
|
+
* `chatgpt_plan_type` claim is a fallback that may lag a plan change. A JWT write
|
|
1857
|
+
* must never overwrite a WHAM-sourced plan observed for the same credential
|
|
1858
|
+
* generation — only a newer generation (token refresh after the WHAM read) may.
|
|
1859
|
+
*/
|
|
1860
|
+
planSource?: "jwt" | "wham";
|
|
1861
|
+
/** Credential generation at which `plan`/`planSource` was recorded. */
|
|
1862
|
+
planCredentialGeneration?: number;
|
|
1776
1863
|
chatgptAccountId?: string;
|
|
1777
1864
|
logLabel?: string;
|
|
1778
1865
|
isMain: boolean;
|
package/src/update/job.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "../config";
|
|
15
15
|
import { isProcessAlive, killProxy } from "../lib/process-control";
|
|
16
16
|
import { selfLaunchArgv } from "../lib/self-launch-argv";
|
|
17
|
+
import { killWindowsSchedulerWrappers } from "../lib/windows-service-wrappers";
|
|
17
18
|
import {
|
|
18
19
|
buildWindowsElevatedArgumentList,
|
|
19
20
|
resolveTrustedWindowsPowerShellExe,
|
|
@@ -1371,26 +1372,16 @@ function stopWindowsServiceWrappersBestEffort(): void {
|
|
|
1371
1372
|
* Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
|
|
1372
1373
|
* `schtasks /end` ends the task instance but often leaves wscript/cmd running the
|
|
1373
1374
|
* `:loop` batch, which brings the proxy back during post-update reclaim.
|
|
1375
|
+
*
|
|
1376
|
+
* This used to match the bare filenames with -like '*name*', which could stop a
|
|
1377
|
+
* wrapper belonging to a DIFFERENT OpenCodex home under the same account. The
|
|
1378
|
+
* shared killer scopes to this home's canonical paths as complete tokens.
|
|
1374
1379
|
*/
|
|
1375
1380
|
function killWindowsServiceWrapperProcesses(): void {
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
"Get-CimInstance Win32_Process | Where-Object {",
|
|
1381
|
-
" if ($_.ProcessId -eq $PID) { return $false };",
|
|
1382
|
-
" $c = $_.CommandLine; if (-not $c) { return $false };",
|
|
1383
|
-
" foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };",
|
|
1384
|
-
" $false",
|
|
1385
|
-
"} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
|
|
1386
|
-
].join(" ");
|
|
1387
|
-
spawnSync(resolveTrustedWindowsPowerShellExe(), [
|
|
1388
|
-
"-NoProfile", "-NoLogo", "-NonInteractive",
|
|
1389
|
-
"-Command", ps,
|
|
1390
|
-
], { stdio: "ignore", timeout: 5000, windowsHide: true });
|
|
1391
|
-
} catch {
|
|
1392
|
-
/* best-effort */
|
|
1393
|
-
}
|
|
1381
|
+
killWindowsSchedulerWrappers({
|
|
1382
|
+
scriptPath: join(getConfigDir(), "opencodex-service.cmd"),
|
|
1383
|
+
launcherPath: join(getConfigDir(), "opencodex-service-launcher.vbs"),
|
|
1384
|
+
});
|
|
1394
1385
|
}
|
|
1395
1386
|
|
|
1396
1387
|
/** Exposed for tests: drives the non-service restart path with injected io. */
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type InstallTreeVerification = { ok: boolean; failures: string[] };
|
|
2
|
+
export function verifyInstallTree(packageDir: string, expectedVersion?: string): InstallTreeVerification;
|
|
3
|
+
export function bootRestoreProbe(
|
|
4
|
+
packageDir: string,
|
|
5
|
+
deps?: { rename?: (from: string, to: string) => void },
|
|
6
|
+
): { action: "none" | "reaped" | "restored" | "failed"; count?: number; from?: string; error?: string };
|
|
7
|
+
export function transactionalNpmUpdate(args: {
|
|
8
|
+
packageDir: string;
|
|
9
|
+
pkgName: string;
|
|
10
|
+
targetVersion?: string;
|
|
11
|
+
tag: string;
|
|
12
|
+
runNpm: (args: string[]) => { status: number | null };
|
|
13
|
+
log?: (line: string) => void;
|
|
14
|
+
deps?: { rename?: (from: string, to: string) => void };
|
|
15
|
+
}): {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
phase: "stage" | "verify" | "swap-backup" | "swap-live" | "post-verify" | "double-fault" | "done";
|
|
18
|
+
error?: string;
|
|
19
|
+
rolledBack?: boolean;
|
|
20
|
+
backup?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transactional npm self-update: stage -> verify -> swap -> rollback (#1942 / #1849).
|
|
3
|
+
*
|
|
4
|
+
* The legacy path ran `npm install -g` straight into the live global tree, so a
|
|
5
|
+
* failure after npm removed the old files left a file-less package skeleton with no
|
|
6
|
+
* recovery. This module stages the new version into a SIBLING directory of the live
|
|
7
|
+
* package (same volume, so directory renames are atomic-ish and never cross devices),
|
|
8
|
+
* verifies the staged tree with a manifest before anything live is touched, then swaps
|
|
9
|
+
* live -> backup -> stage-into-live with a reverse-rename rollback on failure and a
|
|
10
|
+
* recovery marker on double fault.
|
|
11
|
+
*
|
|
12
|
+
* Layout (siblings, never children — a child would travel WITH the live rename and the
|
|
13
|
+
* live dir cannot move into its own subtree):
|
|
14
|
+
* <scopeDir>/opencodex live package
|
|
15
|
+
* <scopeDir>/.ocx-staging-<ts>/ npm --prefix root (contains node_modules/...)
|
|
16
|
+
* <scopeDir>/.ocx-backup-<ts>/opencodex previous live tree during/after the swap
|
|
17
|
+
* <scopeDir>/.ocx-recovery.json double-fault marker with a one-line restore
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Verification manifest for a staged (or live) package tree. */
|
|
24
|
+
export function verifyInstallTree(packageDir, expectedVersion) {
|
|
25
|
+
const failures = [];
|
|
26
|
+
let pkg;
|
|
27
|
+
try {
|
|
28
|
+
pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return { ok: false, failures: ["package.json unreadable: " + (error?.message ?? String(error))] };
|
|
31
|
+
}
|
|
32
|
+
if (expectedVersion && pkg.version !== expectedVersion) {
|
|
33
|
+
failures.push("package.json version " + pkg.version + " != expected " + expectedVersion);
|
|
34
|
+
}
|
|
35
|
+
const launcher = join(packageDir, "bin", "ocx.mjs");
|
|
36
|
+
try {
|
|
37
|
+
const st = statSync(launcher);
|
|
38
|
+
if (!st.isFile() || st.size < 1024) failures.push("bin/ocx.mjs missing or truncated");
|
|
39
|
+
} catch {
|
|
40
|
+
failures.push("bin/ocx.mjs absent");
|
|
41
|
+
}
|
|
42
|
+
// The bundled Bun binary is the load-bearing artifact: without it the launcher exits
|
|
43
|
+
// before serving anything, and a boot probe that called this tree healthy would reap
|
|
44
|
+
// the only backup (review High 3). Size-gate the real binary, not just its package.json.
|
|
45
|
+
const bunPkgDir = join(packageDir, "node_modules", "bun");
|
|
46
|
+
if (existsSync(bunPkgDir)) {
|
|
47
|
+
const bunBinary = findLargestFile(bunPkgDir);
|
|
48
|
+
if (!bunBinary || bunBinary.size < 10 * 1024 * 1024) {
|
|
49
|
+
failures.push("bundled Bun binary missing or truncated (< 10MB)");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Sentinel direct deps: each must have an intact package.json. The bundled Bun dep is
|
|
53
|
+
// the load-bearing one — without it the launcher cannot start the proxy at all.
|
|
54
|
+
const deps = Object.keys(pkg.dependencies ?? {});
|
|
55
|
+
const sentinels = deps.filter(name => name === "bun" || name === "zod").length > 0
|
|
56
|
+
? deps.filter(name => name === "bun" || name === "zod")
|
|
57
|
+
: deps.slice(0, 2);
|
|
58
|
+
for (const name of sentinels) {
|
|
59
|
+
const depPkg = join(packageDir, "node_modules", ...name.split("/"), "package.json");
|
|
60
|
+
if (!existsSync(depPkg)) failures.push("sentinel dependency missing: " + name);
|
|
61
|
+
}
|
|
62
|
+
return failures.length === 0 ? { ok: true, failures: [] } : { ok: false, failures };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function stampedName(prefix) {
|
|
66
|
+
return prefix + "-" + new Date().toISOString().replace(/[:.]/g, "-");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Largest regular file under a directory tree (bounded depth) — locates the Bun binary. */
|
|
70
|
+
function findLargestFile(root, depth = 3) {
|
|
71
|
+
let best;
|
|
72
|
+
let names = [];
|
|
73
|
+
try { names = readdirSync(root); } catch { return undefined; }
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
const full = join(root, name);
|
|
76
|
+
let st;
|
|
77
|
+
try { st = statSync(full); } catch { continue; }
|
|
78
|
+
if (st.isFile()) {
|
|
79
|
+
if (!best || st.size > best.size) best = { path: full, size: st.size };
|
|
80
|
+
} else if (st.isDirectory() && depth > 0) {
|
|
81
|
+
const sub = findLargestFile(full, depth - 1);
|
|
82
|
+
if (sub && (!best || sub.size > best.size)) best = sub;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return best;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Bounded Windows-class rename retry: EPERM/EBUSY/EACCES from AV/indexers clears in ms. */
|
|
89
|
+
function renameWithRetry(rename, from, to, attempts = 5) {
|
|
90
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
91
|
+
try {
|
|
92
|
+
rename(from, to);
|
|
93
|
+
return;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const code = error?.code;
|
|
96
|
+
const retryable = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
97
|
+
if (!retryable || attempt >= attempts - 1) throw error;
|
|
98
|
+
// Synchronous bounded backoff (launcher context has no async loop here).
|
|
99
|
+
const until = Date.now() + 100 * (attempt + 1);
|
|
100
|
+
while (Date.now() < until) { /* spin briefly; total worst case ~1.5s */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function recoveryMarkerPath(scopeDir) {
|
|
106
|
+
return join(scopeDir, ".ocx-recovery.json");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Startup probe: restore a backup when the live tree is broken (D4 power-loss rows). */
|
|
110
|
+
export function bootRestoreProbe(packageDir, deps = {}) {
|
|
111
|
+
const rename = deps.rename ?? renameSync;
|
|
112
|
+
const scopeDir = dirname(packageDir);
|
|
113
|
+
let backups = [];
|
|
114
|
+
try {
|
|
115
|
+
backups = readdirSync(scopeDir).filter(name => name.startsWith(".ocx-backup-")).sort();
|
|
116
|
+
} catch {
|
|
117
|
+
return { action: "none" };
|
|
118
|
+
}
|
|
119
|
+
if (backups.length === 0) return { action: "none" };
|
|
120
|
+
const liveOk = existsSync(join(packageDir, "package.json"))
|
|
121
|
+
&& verifyInstallTree(packageDir).ok;
|
|
122
|
+
const newestBackup = join(scopeDir, backups[backups.length - 1], "opencodex");
|
|
123
|
+
if (liveOk) {
|
|
124
|
+
// Live is healthy: the backups are leftovers from a completed swap. Reap them.
|
|
125
|
+
for (const name of backups) {
|
|
126
|
+
try { rmSync(join(scopeDir, name), { recursive: true, force: true }); } catch { /* keep */ }
|
|
127
|
+
}
|
|
128
|
+
try { rmSync(recoveryMarkerPath(scopeDir), { force: true }); } catch { /* keep */ }
|
|
129
|
+
return { action: "reaped", count: backups.length };
|
|
130
|
+
}
|
|
131
|
+
if (!existsSync(join(newestBackup, "package.json"))) return { action: "none" };
|
|
132
|
+
try {
|
|
133
|
+
try { rmSync(packageDir, { recursive: true, force: true }); } catch { /* may not exist */ }
|
|
134
|
+
rename(newestBackup, packageDir);
|
|
135
|
+
return { action: "restored", from: newestBackup };
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return { action: "failed", error: error?.message ?? String(error) };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run the transactional update. `runNpm(args, opts)` is injected so the caller keeps
|
|
143
|
+
* its hardened npm resolution (npm-invocation.mjs) and logging.
|
|
144
|
+
*/
|
|
145
|
+
export function transactionalNpmUpdate({
|
|
146
|
+
packageDir,
|
|
147
|
+
pkgName,
|
|
148
|
+
targetVersion,
|
|
149
|
+
tag,
|
|
150
|
+
runNpm,
|
|
151
|
+
log = () => {},
|
|
152
|
+
deps = {},
|
|
153
|
+
}) {
|
|
154
|
+
const rename = deps.rename ?? renameSync;
|
|
155
|
+
const scopeDir = dirname(packageDir);
|
|
156
|
+
const stageRoot = join(scopeDir, stampedName(".ocx-staging"));
|
|
157
|
+
// GLOBAL-style staging (-g --prefix): npm nests the package's dependencies INSIDE the
|
|
158
|
+
// package dir, exactly like the live global tree this stage will replace. A local-style
|
|
159
|
+
// install would hoist bun/zod to stageRoot/node_modules — siblings that the swap would
|
|
160
|
+
// leave behind, shipping a dependency-less live tree (release-audit blocker).
|
|
161
|
+
// Layout: <stageRoot>/lib/node_modules/<pkg> on POSIX, <stageRoot>/node_modules/<pkg>
|
|
162
|
+
// on Windows.
|
|
163
|
+
const stagedCandidates = [
|
|
164
|
+
join(stageRoot, "lib", "node_modules", ...pkgName.split("/")),
|
|
165
|
+
join(stageRoot, "node_modules", ...pkgName.split("/")),
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// D1: stage to the side. --prefix keeps npm entirely inside stageRoot; the live tree
|
|
169
|
+
// and the npm bin shims are untouched until the swap. A failure HERE (mkdir EACCES,
|
|
170
|
+
// ENOSPC) must NOT fall back to the destructive legacy install (review High 4): the
|
|
171
|
+
// caller sees a normal phase failure with live untouched.
|
|
172
|
+
try {
|
|
173
|
+
mkdirSync(stageRoot, { recursive: true });
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { ok: false, phase: "stage", error: "could not create staging directory: " + (error?.message ?? String(error)) };
|
|
176
|
+
}
|
|
177
|
+
const spec = pkgName + "@" + (targetVersion || tag);
|
|
178
|
+
log("Staging " + spec + " into " + stageRoot);
|
|
179
|
+
const install = runNpm(["install", "-g", "--prefix", stageRoot, "--no-audit", "--no-fund", spec]);
|
|
180
|
+
if (install.status !== 0) {
|
|
181
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
182
|
+
return { ok: false, phase: "stage", error: "npm staging install failed (" + (install.status ?? "?") + ")" };
|
|
183
|
+
}
|
|
184
|
+
const stagedPackage = stagedCandidates.find(dir => existsSync(join(dir, "package.json")));
|
|
185
|
+
if (!stagedPackage) {
|
|
186
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
187
|
+
return { ok: false, phase: "verify", error: "staged package directory not found under " + stageRoot };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// D2: verify INSIDE the stage. Live is still untouched on any failure here.
|
|
191
|
+
const staged = verifyInstallTree(stagedPackage, targetVersion || undefined);
|
|
192
|
+
if (!staged.ok) {
|
|
193
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
194
|
+
return { ok: false, phase: "verify", error: "staged tree failed verification: " + staged.failures.join("; ") };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// D3: swap. live -> backup, stage -> live, re-verify live, rollback on failure.
|
|
198
|
+
const backupRoot = join(scopeDir, stampedName(".ocx-backup"));
|
|
199
|
+
const backupPackage = join(backupRoot, "opencodex");
|
|
200
|
+
try {
|
|
201
|
+
mkdirSync(backupRoot, { recursive: true });
|
|
202
|
+
} catch (error) {
|
|
203
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
204
|
+
return { ok: false, phase: "swap-backup", error: "could not create backup directory: " + (error?.message ?? String(error)) };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
renameWithRetry(rename, packageDir, backupPackage);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
210
|
+
try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
211
|
+
return { ok: false, phase: "swap-backup", error: "could not move live tree aside: " + (error?.message ?? String(error)) };
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
renameWithRetry(rename, stagedPackage, packageDir);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// Rollback: reverse the first rename. Double fault leaves the recovery marker.
|
|
217
|
+
try {
|
|
218
|
+
renameWithRetry(rename, backupPackage, packageDir);
|
|
219
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
220
|
+
try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
221
|
+
return { ok: false, phase: "swap-live", rolledBack: true, error: "could not place staged tree: " + (error?.message ?? String(error)) };
|
|
222
|
+
} catch (rollbackError) {
|
|
223
|
+
writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({
|
|
224
|
+
at: new Date().toISOString(),
|
|
225
|
+
backup: backupPackage,
|
|
226
|
+
live: packageDir,
|
|
227
|
+
restore: 'move "' + backupPackage + '" back to "' + packageDir + '"',
|
|
228
|
+
error: String(error?.message ?? error),
|
|
229
|
+
rollbackError: String(rollbackError?.message ?? rollbackError),
|
|
230
|
+
}, null, 2));
|
|
231
|
+
return { ok: false, phase: "double-fault", rolledBack: false, error: "swap and rollback both failed; recovery marker written at " + recoveryMarkerPath(scopeDir) };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const liveCheck = verifyInstallTree(packageDir, targetVersion || undefined);
|
|
235
|
+
if (!liveCheck.ok) {
|
|
236
|
+
try {
|
|
237
|
+
rmSync(packageDir, { recursive: true, force: true });
|
|
238
|
+
rename(backupPackage, packageDir);
|
|
239
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
240
|
+
try { rmSync(backupRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
241
|
+
return { ok: false, phase: "post-verify", rolledBack: true, error: "live tree failed post-swap verification: " + liveCheck.failures.join("; ") };
|
|
242
|
+
} catch (rollbackError) {
|
|
243
|
+
writeFileSync(recoveryMarkerPath(scopeDir), JSON.stringify({
|
|
244
|
+
at: new Date().toISOString(),
|
|
245
|
+
backup: backupPackage,
|
|
246
|
+
live: packageDir,
|
|
247
|
+
restore: 'move "' + backupPackage + '" back to "' + packageDir + '"',
|
|
248
|
+
error: "post-swap verification failed: " + liveCheck.failures.join("; "),
|
|
249
|
+
rollbackError: String(rollbackError?.message ?? rollbackError),
|
|
250
|
+
}, null, 2));
|
|
251
|
+
return { ok: false, phase: "double-fault", rolledBack: false, error: "post-verify rollback failed; recovery marker written" };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Success: stage scaffolding is disposable now; the backup stays until the next
|
|
255
|
+
// healthy boot reaps it (bootRestoreProbe) — the process that spawned this update may
|
|
256
|
+
// still hold the old cwd.
|
|
257
|
+
try { rmSync(stageRoot, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
258
|
+
return { ok: true, phase: "done", backup: backupPackage };
|
|
259
|
+
}
|
package/src/usage/cost.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
getModelMetadata,
|
|
15
15
|
resolveMetadataProvider,
|
|
16
16
|
} from "../generated/model-metadata";
|
|
17
|
-
import type { OcxUsage } from "../types";
|
|
17
|
+
import type { AttemptTierOutcome, OcxUsage } from "../types";
|
|
18
18
|
import { baseProviderLabel, canonicalUsageProviderLabel } from "../providers/label";
|
|
19
19
|
import type { PersistedUsageAttempt, UsageStatus } from "./log";
|
|
20
20
|
import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
|
|
@@ -44,6 +44,7 @@ export interface ServiceTierContext {
|
|
|
44
44
|
responseServiceTier?: string;
|
|
45
45
|
requestedServiceTier?: string;
|
|
46
46
|
configuredServiceTier?: string;
|
|
47
|
+
tierOutcome?: AttemptTierOutcome;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export interface CostTokens {
|
|
@@ -349,6 +350,7 @@ export type ServiceTierInput = string | ServiceTierContext;
|
|
|
349
350
|
* and long-context exclusivity depends on that distinction.
|
|
350
351
|
*/
|
|
351
352
|
export function serviceTierContext(entry: ServiceTierContext): ServiceTierContext {
|
|
353
|
+
if (entry.tierOutcome) return serviceTierContextFromOutcome(entry.tierOutcome);
|
|
352
354
|
return {
|
|
353
355
|
responseServiceTier: entry.responseServiceTier,
|
|
354
356
|
requestedServiceTier: entry.requestedServiceTier,
|
|
@@ -356,6 +358,30 @@ export function serviceTierContext(entry: ServiceTierContext): ServiceTierContex
|
|
|
356
358
|
};
|
|
357
359
|
}
|
|
358
360
|
|
|
361
|
+
/** Convert one adapter-observed attempt outcome into the existing pricing provenance shape. */
|
|
362
|
+
export function serviceTierContextFromOutcome(outcome: AttemptTierOutcome): ServiceTierContext {
|
|
363
|
+
if (outcome.canonical === "priority" && outcome.confirmation === "confirmed") {
|
|
364
|
+
return { responseServiceTier: "priority" };
|
|
365
|
+
}
|
|
366
|
+
if (outcome.responseServiceTier !== undefined) {
|
|
367
|
+
return { responseServiceTier: outcome.responseServiceTier };
|
|
368
|
+
}
|
|
369
|
+
if (outcome.canonical === "priority" && outcome.confirmation === "assumed") {
|
|
370
|
+
return { requestedServiceTier: "priority" };
|
|
371
|
+
}
|
|
372
|
+
// An unclassified route makes no canonical Fast claim, but its adapter can still prove that
|
|
373
|
+
// it serialized a caller tier. Preserve that wire evidence instead of discarding the legacy
|
|
374
|
+
// top-level pricing signal merely because B0 added an outcome row.
|
|
375
|
+
if (
|
|
376
|
+
outcome.fastOutcome === "unknown"
|
|
377
|
+
&& outcome.wireKind === "service-tier"
|
|
378
|
+
&& typeof outcome.wireValue === "string"
|
|
379
|
+
) {
|
|
380
|
+
return { requestedServiceTier: outcome.wireValue };
|
|
381
|
+
}
|
|
382
|
+
return {};
|
|
383
|
+
}
|
|
384
|
+
|
|
359
385
|
function tierScalar(tier?: ServiceTierInput): string | undefined {
|
|
360
386
|
return typeof tier === "string" ? tier : tier && effectiveServiceTier(tier);
|
|
361
387
|
}
|
|
@@ -428,7 +454,7 @@ function applyPriorityMultiplier(
|
|
|
428
454
|
* missing so combos can fail closed.
|
|
429
455
|
*/
|
|
430
456
|
export function estimateAttemptCost(
|
|
431
|
-
attempt: Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus">,
|
|
457
|
+
attempt: Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus" | "tierOutcome">,
|
|
432
458
|
overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
|
|
433
459
|
serviceTier?: ServiceTierInput,
|
|
434
460
|
userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
|
|
@@ -438,15 +464,18 @@ export function estimateAttemptCost(
|
|
|
438
464
|
if (!tokens) return null;
|
|
439
465
|
const price = resolveMatchedPrice(attempt.provider, attempt.model, overlays, userOverlays);
|
|
440
466
|
if (!price) return null;
|
|
467
|
+
const attemptServiceTier = attempt.tierOutcome
|
|
468
|
+
? serviceTierContextFromOutcome(attempt.tierOutcome)
|
|
469
|
+
: serviceTier;
|
|
441
470
|
const [tieredCost4, contextTier] = applyContextTier(
|
|
442
|
-
price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens,
|
|
471
|
+
price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
|
|
443
472
|
);
|
|
444
473
|
// Exclusive both ways: if the long rate applied, the request was NOT served as
|
|
445
474
|
// Fast (Fast does not support long context), so the Fast multiplier must not
|
|
446
475
|
// also apply — otherwise a downgraded request bills at both rates.
|
|
447
476
|
const [effectiveCost4, multiplier] = contextTier
|
|
448
477
|
? [tieredCost4, 1] as const
|
|
449
|
-
: applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model,
|
|
478
|
+
: applyPriorityMultiplier(tieredCost4, attempt.provider, attempt.model, attemptServiceTier);
|
|
450
479
|
return {
|
|
451
480
|
ordinal: attempt.ordinal,
|
|
452
481
|
provider: attempt.provider,
|
|
@@ -465,7 +494,7 @@ export function estimateAttemptCost(
|
|
|
465
494
|
* attempt is unpriced or unnormalizable, return null rather than a partial sum.
|
|
466
495
|
*/
|
|
467
496
|
export function estimateComboCost(
|
|
468
|
-
attempts: readonly Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus">[],
|
|
497
|
+
attempts: readonly Pick<PersistedUsageAttempt, "ordinal" | "provider" | "model" | "usage" | "usageStatus" | "tierOutcome">[],
|
|
469
498
|
overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
|
|
470
499
|
serviceTier?: ServiceTierInput,
|
|
471
500
|
userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
|