@bitkyc08/opencodex 2.14.0 → 2.14.2
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/README.md +55 -0
- package/gui/dist/assets/index-DUCH59lJ.css +1 -0
- package/gui/dist/assets/index-DUyQeU1j.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +46 -6
- package/src/adapters/cursor/request-builder.ts +54 -10
- package/src/adapters/cursor/tool-definitions.ts +24 -0
- package/src/adapters/kiro.ts +10 -1
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +7 -4
- package/src/adapters/openai-responses-url.ts +14 -0
- package/src/adapters/openai-responses.ts +111 -2
- package/src/adapters/tool-catalog-nudge.ts +26 -4
- package/src/bridge.ts +50 -3
- package/src/cli/init.ts +4 -17
- package/src/codex/auth-api.ts +2 -74
- package/src/codex/catalog/effort.ts +2 -1
- package/src/codex/catalog/metadata.ts +62 -12
- package/src/codex/catalog/native-models.ts +27 -0
- package/src/codex/catalog/parsing.ts +27 -8
- package/src/codex/catalog/provider-fetch.ts +47 -5
- package/src/codex/catalog/sync.ts +31 -8
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +14 -3
- package/src/codex/model-cache.ts +7 -1
- package/src/codex/native-main-claim.ts +13 -2
- package/src/config.ts +79 -4
- package/src/generated/compatibility-version.json +74 -46
- package/src/lab/ledger/store.ts +0 -18
- package/src/lab/subject/installation-salt.ts +13 -2
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/tool-argument-integers.ts +158 -0
- package/src/oauth/nous.ts +58 -9
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/command-code-efforts.ts +18 -0
- package/src/providers/model-rename-migration.ts +202 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +17 -10
- package/src/responses/spill-store.ts +5 -1
- package/src/responses/state.ts +50 -2
- package/src/router.ts +12 -1
- package/src/server/index.ts +3 -2
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/config-routes.ts +51 -16
- package/src/server/management/logs-usage-routes.ts +48 -10
- package/src/server/management/provider-routes.ts +2 -1
- package/src/server/management/usage-summary-cache.ts +7 -1
- package/src/server/responses/collaboration.ts +12 -2
- package/src/server/responses/core.ts +33 -17
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/ws-upstream.ts +199 -0
- package/src/server/startup-health-cache.ts +12 -0
- package/src/usage/log.ts +430 -12
- package/src/vision/index.ts +25 -4
- package/src/vision/timeout-bounds.ts +9 -0
- package/gui/dist/assets/index-BNVYzdn0.css +0 -1
- package/gui/dist/assets/index-Co12XTT-.js +0 -76
package/src/usage/log.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { createHash, type Hash } from "node:crypto";
|
|
1
2
|
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { getConfigDir } from "../config";
|
|
5
|
+
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
|
|
4
6
|
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
5
7
|
import { usageDisplayTotalTokens } from "./totals";
|
|
6
8
|
import type { OcxUsage } from "../types";
|
|
@@ -448,7 +450,16 @@ let usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
|
|
|
448
450
|
const MANAGEMENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
449
451
|
const RECENT_USAGE_MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
450
452
|
const MANAGEMENT_USAGE_READ_CHUNK_BYTES = 1024 * 1024;
|
|
451
|
-
const
|
|
453
|
+
const MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT = 500_000;
|
|
454
|
+
/**
|
|
455
|
+
* Row cap for a management snapshot. Overridable only so tests can reach the cap without
|
|
456
|
+
* building a half-million-row fixture; production always uses the default.
|
|
457
|
+
*/
|
|
458
|
+
let MANAGEMENT_USAGE_MAX_ENTRIES = MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT;
|
|
459
|
+
|
|
460
|
+
export function setManagementUsageMaxEntriesForTests(value: number | null): void {
|
|
461
|
+
MANAGEMENT_USAGE_MAX_ENTRIES = value ?? MANAGEMENT_USAGE_MAX_ENTRIES_DEFAULT;
|
|
462
|
+
}
|
|
452
463
|
const MANAGEMENT_USAGE_FLIGHT_STALE_MS = 30_000;
|
|
453
464
|
export interface ManagementUsageSnapshot {
|
|
454
465
|
entries: PersistedUsageEntry[];
|
|
@@ -456,14 +467,156 @@ export interface ManagementUsageSnapshot {
|
|
|
456
467
|
truncatedPrefixBytes: number;
|
|
457
468
|
entriesTruncated: boolean;
|
|
458
469
|
entriesDropped: number;
|
|
470
|
+
/** Digest of the covered prefix, used to detect an in-place rewrite before reuse. */
|
|
471
|
+
prefixDigest: string;
|
|
472
|
+
/**
|
|
473
|
+
* Byte offset where the RETAINED ROWS begin.
|
|
474
|
+
*
|
|
475
|
+
* Distinct from `truncatedPrefixBytes`, which is the API-visible "bytes skipped by the
|
|
476
|
+
* byte window" signal and must stay independent of entry-count truncation. When the
|
|
477
|
+
* entry cap drops rows, those bytes are not window truncation, but the retained rows do
|
|
478
|
+
* start later -- this field tracks that so the byte accounting stays exact.
|
|
479
|
+
*/
|
|
480
|
+
rowsBeginAtBytes: number;
|
|
481
|
+
/**
|
|
482
|
+
* Byte length of each returned row, in order, including its newline.
|
|
483
|
+
*
|
|
484
|
+
* Lets a later read trim rows that have fallen out of the bounded window without
|
|
485
|
+
* re-reading or re-parsing them, which is what keeps the returned set equal to the
|
|
486
|
+
* window the caller asked for.
|
|
487
|
+
*/
|
|
488
|
+
entryLengths: number[];
|
|
489
|
+
/** Unparseable bytes after the last returned row; the next read folds them forward. */
|
|
490
|
+
trailingSkippedBytes: number;
|
|
459
491
|
}
|
|
460
492
|
let managementUsageReadInflight: {
|
|
461
493
|
key: string;
|
|
494
|
+
openedSize: number;
|
|
462
495
|
promise: Promise<ManagementUsageSnapshot>;
|
|
463
496
|
startedAt: number;
|
|
464
497
|
abort: AbortController;
|
|
465
498
|
} | null = null;
|
|
466
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Append-tolerant snapshot of the last management read.
|
|
502
|
+
*
|
|
503
|
+
* The management reader parses a 64 MiB tail into ~53k objects, which costs roughly
|
|
504
|
+
* 640 MB of transient RSS per cold call. The JS objects are collected promptly, but
|
|
505
|
+
* the allocator does not return those pages, so every cold miss ratchets process RSS
|
|
506
|
+
* upward and never comes back down (observed: 7.9 GiB RSS against a 130 MB JS heap).
|
|
507
|
+
*
|
|
508
|
+
* Reparsing an unchanged prefix is what makes that transient recur. `usage.jsonl` is
|
|
509
|
+
* append-only under a stable identity, so when the file has only grown we keep the
|
|
510
|
+
* previously parsed rows and parse just the appended bytes. This is retained state, so
|
|
511
|
+
* it is registered with the app-owned memory budget and is evictable under pressure.
|
|
512
|
+
*/
|
|
513
|
+
interface RetainedUsageSnapshot {
|
|
514
|
+
identityKey: string;
|
|
515
|
+
maxReadBytes: number;
|
|
516
|
+
/** Absolute end offset in the file that `entries` already covers. */
|
|
517
|
+
coveredThroughBytes: number;
|
|
518
|
+
/**
|
|
519
|
+
* Digest of the last bytes of the covered prefix, re-verified before extending.
|
|
520
|
+
*
|
|
521
|
+
* Identity (path/dev/ino/birthtime) intentionally ignores size and mtime so appends
|
|
522
|
+
* can share work, which also means an in-place rewrite that keeps the inode is
|
|
523
|
+
* invisible to it. A hand-edit or external compaction can therefore replace history
|
|
524
|
+
* under a stable identity without shrinking the file. Re-reading this trailing window
|
|
525
|
+
* catches that: if the bytes behind `coveredThroughBytes` changed, the retained rows
|
|
526
|
+
* no longer describe the file and must not be extended.
|
|
527
|
+
*/
|
|
528
|
+
prefixDigest: string;
|
|
529
|
+
/** Bytes of the file skipped ahead of the retained window. */
|
|
530
|
+
truncatedPrefixBytes: number;
|
|
531
|
+
/** Byte length of each retained row, so out-of-window rows can be trimmed exactly. */
|
|
532
|
+
entryLengths: number[];
|
|
533
|
+
/** Unparseable bytes after the last retained row; folded into the next row's span. */
|
|
534
|
+
trailingSkippedBytes: number;
|
|
535
|
+
/** Byte offset where the retained rows begin; see ManagementUsageSnapshot. */
|
|
536
|
+
rowsBeginAtBytes: number;
|
|
537
|
+
entries: PersistedUsageEntry[];
|
|
538
|
+
entriesTruncated: boolean;
|
|
539
|
+
entriesDropped: number;
|
|
540
|
+
revision: UsageLogRevision;
|
|
541
|
+
retainedAt: number;
|
|
542
|
+
approxBytes: number;
|
|
543
|
+
}
|
|
544
|
+
let retainedUsageSnapshot: RetainedUsageSnapshot | null = null;
|
|
545
|
+
|
|
546
|
+
/** Rough per-row retained cost; exact sizing would cost another full serialization pass. */
|
|
547
|
+
const RETAINED_USAGE_ENTRY_BYTES = 512;
|
|
548
|
+
|
|
549
|
+
/** Chunk size used when digesting a retained region. */
|
|
550
|
+
const RETAINED_USAGE_DIGEST_CHUNK_BYTES = 1024 * 1024;
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Digest a byte range into `hash`; false when it cannot be read.
|
|
554
|
+
*
|
|
555
|
+
* Deliberately not sampled. A sampled digest covers a vanishing fraction of a large
|
|
556
|
+
* prefix (32 KiB of 64 MiB is 0.05%), so an ordinary fixed-width in-place edit -- a
|
|
557
|
+
* redaction script fixing one field, a compaction rewriting a middle region -- lands in
|
|
558
|
+
* a gap by default and the stale rows are served.
|
|
559
|
+
*
|
|
560
|
+
* Hashing from byte 0 on every call is also wrong: that is O(file) per poll while the
|
|
561
|
+
* read it protects is capped at maxReadBytes, so the ratio degrades as the ledger grows
|
|
562
|
+
* and becomes SLOWER than a full read past roughly 1-2 GB. Only the retained REGION
|
|
563
|
+
* (truncatedPrefixBytes..coveredThroughBytes) is hashed. It is never wider than the
|
|
564
|
+
* window, and bytes below the retained start describe no retained row, so reading them
|
|
565
|
+
* would prove nothing.
|
|
566
|
+
*/
|
|
567
|
+
function updateUsageDigest(hash: Hash, fd: number, from: number, to: number): boolean {
|
|
568
|
+
if (to <= from) return true;
|
|
569
|
+
const buffer = Buffer.allocUnsafe(Math.min(RETAINED_USAGE_DIGEST_CHUNK_BYTES, to - from));
|
|
570
|
+
for (let position = from; position < to;) {
|
|
571
|
+
const length = Math.min(buffer.byteLength, to - position);
|
|
572
|
+
let offset = 0;
|
|
573
|
+
while (offset < length) {
|
|
574
|
+
const read = readSync(fd, buffer, offset, length - offset, position + offset);
|
|
575
|
+
if (read === 0) return false;
|
|
576
|
+
offset += read;
|
|
577
|
+
}
|
|
578
|
+
hash.update(buffer.subarray(0, length));
|
|
579
|
+
position += length;
|
|
580
|
+
}
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Digest of `from`..`to`; null when it cannot be read.
|
|
586
|
+
*
|
|
587
|
+
* The range is bound into the digest so a region cannot be confused with an equal-length
|
|
588
|
+
* region at a different offset.
|
|
589
|
+
*/
|
|
590
|
+
function usageRegionDigest(fd: number, from: number, to: number): string | null {
|
|
591
|
+
if (to <= from) return `${from}:${to}:empty`;
|
|
592
|
+
const hash = createHash("sha256");
|
|
593
|
+
if (!updateUsageDigest(hash, fd, from, to)) return null;
|
|
594
|
+
return `${from}:${to}:${hash.digest("hex")}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function retainedUsageSnapshotBytes(entries: PersistedUsageEntry[]): number {
|
|
598
|
+
return entries.length * RETAINED_USAGE_ENTRY_BYTES;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export function discardRetainedUsageSnapshot(): number {
|
|
602
|
+
const released = retainedUsageSnapshot?.approxBytes ?? 0;
|
|
603
|
+
retainedUsageSnapshot = null;
|
|
604
|
+
return released;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export function retainedUsageSnapshotStats(): {
|
|
608
|
+
count: number;
|
|
609
|
+
bytes: number;
|
|
610
|
+
oldestAt: number | null;
|
|
611
|
+
} {
|
|
612
|
+
if (!retainedUsageSnapshot) return { count: 0, bytes: 0, oldestAt: null };
|
|
613
|
+
return {
|
|
614
|
+
count: 1,
|
|
615
|
+
bytes: retainedUsageSnapshot.approxBytes,
|
|
616
|
+
oldestAt: retainedUsageSnapshot.retainedAt,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
467
620
|
/** Test-only observability for proving that unchanged prefixes are not reparsed. */
|
|
468
621
|
export function usageReadCacheStatsForTests(): Readonly<typeof usageReadCacheStats> {
|
|
469
622
|
return { ...usageReadCacheStats };
|
|
@@ -473,6 +626,7 @@ export function resetUsageReadCacheForTests(): void {
|
|
|
473
626
|
usageReadCacheStats = { fullReads: 0, tailReads: 0, parsedLines: 0 };
|
|
474
627
|
managementUsageReadInflight?.abort.abort();
|
|
475
628
|
managementUsageReadInflight = null;
|
|
629
|
+
retainedUsageSnapshot = null;
|
|
476
630
|
}
|
|
477
631
|
|
|
478
632
|
function readExactly(fd: number, length: number, position: number): Buffer | null {
|
|
@@ -512,6 +666,12 @@ export function usageLogRevisionKey(revision: UsageLogRevision | null): string {
|
|
|
512
666
|
].join("\0");
|
|
513
667
|
}
|
|
514
668
|
|
|
669
|
+
/** Identity of the usage ledger file, excluding size/mtime/ctime so appends can share work. */
|
|
670
|
+
export function usageLogIdentityKey(revision: UsageLogRevision | null): string {
|
|
671
|
+
if (!revision) return "missing";
|
|
672
|
+
return [revision.path, revision.dev, revision.ino, revision.birthtimeMs].join("\0");
|
|
673
|
+
}
|
|
674
|
+
|
|
515
675
|
export function currentUsageLogRevision(): UsageLogRevision | null {
|
|
516
676
|
const path = usageLogPath();
|
|
517
677
|
if (!existsSync(path)) return null;
|
|
@@ -527,23 +687,71 @@ export function currentUsageLogRevision(): UsageLogRevision | null {
|
|
|
527
687
|
async function parseUsageTextCooperatively(text: string, signal: AbortSignal): Promise<{
|
|
528
688
|
entries: PersistedUsageEntry[];
|
|
529
689
|
entriesDropped: number;
|
|
690
|
+
entryLengths: number[];
|
|
691
|
+
/** Bytes of unparseable lines after the final accepted row. */
|
|
692
|
+
trailingSkippedBytes: number;
|
|
693
|
+
/** Bytes of rows removed by the entry cap, which move into the skipped prefix. */
|
|
694
|
+
cappedPrefixBytes: number;
|
|
530
695
|
}> {
|
|
531
|
-
|
|
696
|
+
// Split on "\n" only, so a CRLF line keeps its "\r" and its byte length stays exact.
|
|
697
|
+
// Splitting on /\r?\n/ consumes two bytes but leaves no way to tell that it did, which
|
|
698
|
+
// made the recorded lengths short by one byte per line on a CRLF ledger and failed the
|
|
699
|
+
// accounting self-check. JSON.parse tolerates the trailing "\r".
|
|
700
|
+
const lines = text.split("\n");
|
|
532
701
|
usageReadCacheStats.parsedLines += lines.filter(line => line.trim()).length;
|
|
533
702
|
const entries: PersistedUsageEntry[] = [];
|
|
703
|
+
// Byte length of each accepted row including its newline, so a later read can trim
|
|
704
|
+
// rows that fall out of the bounded window without re-reading the file.
|
|
705
|
+
const entryLengths: number[] = [];
|
|
534
706
|
const batchSize = 1_000;
|
|
707
|
+
// Bytes of lines that did not yield an entry (malformed JSON, missing requestId, a
|
|
708
|
+
// torn final write). They still occupy space in the file, so they are folded into the
|
|
709
|
+
// next accepted row's recorded length. Dropping them would make the recorded lengths
|
|
710
|
+
// sum to less than the real byte span, and the window trim -- which walks forward by
|
|
711
|
+
// summing those lengths -- would consume extra rows to reach the window start,
|
|
712
|
+
// silently hiding history and desynchronizing truncatedPrefixBytes.
|
|
713
|
+
let pendingSkippedBytes = 0;
|
|
535
714
|
for (let offset = 0; offset < lines.length; offset += batchSize) {
|
|
536
715
|
if (signal.aborted) throw signal.reason;
|
|
537
|
-
|
|
716
|
+
const batch = lines.slice(offset, offset + batchSize);
|
|
717
|
+
for (let index = 0; index < batch.length; index++) {
|
|
718
|
+
const line = batch[index]!;
|
|
719
|
+
// The split leaves a trailing "" after the final newline; it occupies no bytes.
|
|
720
|
+
const isLastLine = offset + index === lines.length - 1;
|
|
721
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + (isLastLine && line === "" ? 0 : 1);
|
|
722
|
+
const parsed = parseUsageLines([line]);
|
|
723
|
+
if (parsed.length === 0) {
|
|
724
|
+
pendingSkippedBytes += lineBytes;
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
entries.push(parsed[0]!);
|
|
728
|
+
entryLengths.push(lineBytes + pendingSkippedBytes);
|
|
729
|
+
pendingSkippedBytes = 0;
|
|
730
|
+
}
|
|
538
731
|
if (offset + batchSize < lines.length) {
|
|
539
732
|
// JSON parsing dominates large-log startup. Yield between bounded batches so
|
|
540
733
|
// Bun can continue serving health and settings requests on the same thread.
|
|
541
734
|
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
542
735
|
}
|
|
543
736
|
}
|
|
544
|
-
|
|
737
|
+
// Skipped bytes AFTER the last accepted row belong to no entry length, so report them
|
|
738
|
+
// separately; the trim arithmetic adds them back to keep lengths summing to the span.
|
|
739
|
+
const trailingSkippedBytes = pendingSkippedBytes;
|
|
740
|
+
if (entries.length <= MANAGEMENT_USAGE_MAX_ENTRIES) {
|
|
741
|
+
return { entries, entriesDropped: 0, entryLengths, trailingSkippedBytes, cappedPrefixBytes: 0 };
|
|
742
|
+
}
|
|
545
743
|
const entriesDropped = entries.length - MANAGEMENT_USAGE_MAX_ENTRIES;
|
|
546
|
-
return {
|
|
744
|
+
return {
|
|
745
|
+
entries: entries.slice(-MANAGEMENT_USAGE_MAX_ENTRIES),
|
|
746
|
+
entriesDropped,
|
|
747
|
+
entryLengths: entryLengths.slice(-MANAGEMENT_USAGE_MAX_ENTRIES),
|
|
748
|
+
trailingSkippedBytes,
|
|
749
|
+
// Bytes of the rows the cap removed. The caller adds them to its skipped prefix so
|
|
750
|
+
// the recorded lengths keep summing to the byte span they describe.
|
|
751
|
+
cappedPrefixBytes: entryLengths
|
|
752
|
+
.slice(0, entryLengths.length - MANAGEMENT_USAGE_MAX_ENTRIES)
|
|
753
|
+
.reduce((total, length) => total + length, 0),
|
|
754
|
+
};
|
|
547
755
|
}
|
|
548
756
|
|
|
549
757
|
async function readUsageEntriesFullCooperatively(
|
|
@@ -584,12 +792,181 @@ async function readUsageEntriesFullCooperatively(
|
|
|
584
792
|
}
|
|
585
793
|
const parsed = await parseUsageTextCooperatively(bytes.toString("utf-8"), signal);
|
|
586
794
|
usageReadCacheStats.fullReads += 1;
|
|
795
|
+
// Rows removed by the entry cap start the retained rows later in the file. That is
|
|
796
|
+
// NOT byte-window truncation, so it must not move truncatedPrefixBytes -- the two
|
|
797
|
+
// signals are independent in the API. It is tracked separately for the byte
|
|
798
|
+
// accounting the incremental reader relies on.
|
|
799
|
+
const rowsBeginAtBytes = truncatedPrefixBytes + parsed.cappedPrefixBytes;
|
|
800
|
+
// Digest the exact prefix these rows describe, so a later incremental read can
|
|
801
|
+
// prove the file was appended to rather than rewritten under the same inode.
|
|
802
|
+
const prefixDigest = usageRegionDigest(fd, rowsBeginAtBytes, Number(stat.size));
|
|
803
|
+
if (prefixDigest === null) throw new Error("usage log changed while it was being read");
|
|
587
804
|
return {
|
|
588
805
|
entries: parsed.entries,
|
|
589
806
|
revision: usageLogRevision(path, stat),
|
|
590
807
|
truncatedPrefixBytes,
|
|
591
808
|
entriesTruncated: parsed.entriesDropped > 0,
|
|
592
809
|
entriesDropped: parsed.entriesDropped,
|
|
810
|
+
prefixDigest,
|
|
811
|
+
entryLengths: parsed.entryLengths,
|
|
812
|
+
trailingSkippedBytes: parsed.trailingSkippedBytes,
|
|
813
|
+
rowsBeginAtBytes,
|
|
814
|
+
};
|
|
815
|
+
} finally {
|
|
816
|
+
if (fd !== undefined) closeSync(fd);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Parse only the bytes appended since the retained snapshot's covered offset.
|
|
822
|
+
*
|
|
823
|
+
* Returns null when the retained snapshot cannot be extended safely — a different
|
|
824
|
+
* identity or read window, a file that shrank (replacement/truncation), or a covered
|
|
825
|
+
* offset that no longer sits on a record boundary. Callers then fall back to a full
|
|
826
|
+
* bounded read.
|
|
827
|
+
*/
|
|
828
|
+
async function readUsageEntriesIncrementally(
|
|
829
|
+
path: string,
|
|
830
|
+
signal: AbortSignal,
|
|
831
|
+
maxReadBytes: number,
|
|
832
|
+
retained: RetainedUsageSnapshot,
|
|
833
|
+
): Promise<ManagementUsageSnapshot | null> {
|
|
834
|
+
let fd: number | undefined;
|
|
835
|
+
try {
|
|
836
|
+
fd = openSync(path, "r");
|
|
837
|
+
const stat = fstatSync(fd);
|
|
838
|
+
const revision = usageLogRevision(path, stat);
|
|
839
|
+
if (usageLogIdentityKey(revision) !== retained.identityKey) return null;
|
|
840
|
+
const size = Number(stat.size);
|
|
841
|
+
// A shrink means truncation or replacement-in-place; the retained rows may no
|
|
842
|
+
// longer correspond to file contents, so refuse to extend them.
|
|
843
|
+
if (size < retained.coveredThroughBytes) return null;
|
|
844
|
+
// Verify the retained REGION is unchanged before anything is reused. Identity keeps
|
|
845
|
+
// dev/ino/birthtime, and an append and an in-place rewrite both move mtime/ctime
|
|
846
|
+
// forward, so only the bytes themselves settle it.
|
|
847
|
+
//
|
|
848
|
+
// Only `truncatedPrefixBytes..coveredThroughBytes` is hashed: that is exactly the
|
|
849
|
+
// span the retained rows were parsed from, and after trimming it is never wider than
|
|
850
|
+
// maxReadBytes. Hashing from byte 0 instead would make every poll O(file) -- cheaper
|
|
851
|
+
// than a reparse on a 245 MB ledger but MORE expensive past roughly 1-2 GB, turning
|
|
852
|
+
// this optimization into a pessimization on exactly the growth curve an append-only
|
|
853
|
+
// ledger follows. Bytes before the retained start are not described by any retained
|
|
854
|
+
// row, so re-reading them proves nothing.
|
|
855
|
+
const covered = usageRegionDigest(fd, retained.rowsBeginAtBytes, retained.coveredThroughBytes);
|
|
856
|
+
if (covered === null || covered !== retained.prefixDigest) return null;
|
|
857
|
+
// Read and parse ONLY the appended bytes.
|
|
858
|
+
let appendedEntries: PersistedUsageEntry[] = [];
|
|
859
|
+
let appendedLengths: number[] = [];
|
|
860
|
+
let appendedDropped = 0;
|
|
861
|
+
let appendedTrailingSkipped = retained.trailingSkippedBytes;
|
|
862
|
+
if (size > retained.coveredThroughBytes) {
|
|
863
|
+
// The covered offset must land immediately after a newline, or the retained rows
|
|
864
|
+
// and the appended text do not join on a record boundary.
|
|
865
|
+
if (retained.coveredThroughBytes > 0) {
|
|
866
|
+
const preceding = readExactly(fd, 1, retained.coveredThroughBytes - 1);
|
|
867
|
+
if (preceding === null || preceding[0] !== 0x0a) return null;
|
|
868
|
+
}
|
|
869
|
+
const chunks: Buffer[] = [];
|
|
870
|
+
for (let position = retained.coveredThroughBytes; position < size;) {
|
|
871
|
+
if (signal.aborted) throw signal.reason;
|
|
872
|
+
const length = Math.min(MANAGEMENT_USAGE_READ_CHUNK_BYTES, size - position);
|
|
873
|
+
const chunk = readExactly(fd, length, position);
|
|
874
|
+
if (chunk === null) throw new Error("usage log changed while it was being read");
|
|
875
|
+
chunks.push(chunk);
|
|
876
|
+
position += length;
|
|
877
|
+
}
|
|
878
|
+
const appended = await parseUsageTextCooperatively(Buffer.concat(chunks).toString("utf-8"), signal);
|
|
879
|
+
appendedEntries = appended.entries;
|
|
880
|
+
appendedLengths = appended.entryLengths;
|
|
881
|
+
appendedDropped = appended.entriesDropped;
|
|
882
|
+
// A capped appended chunk is not joinable: its dropped rows sit between the
|
|
883
|
+
// retained rows and the kept ones, so the lengths no longer describe a contiguous
|
|
884
|
+
// span. Fall back to a full read.
|
|
885
|
+
if (appended.cappedPrefixBytes > 0) return null;
|
|
886
|
+
// If the appended chunk produced rows, its own trailing skipped bytes become the
|
|
887
|
+
// new trailing remainder; otherwise the earlier remainder still stands and the new
|
|
888
|
+
// skipped bytes add to it.
|
|
889
|
+
appendedTrailingSkipped = appended.entries.length > 0
|
|
890
|
+
? appended.trailingSkippedBytes
|
|
891
|
+
: retained.trailingSkippedBytes + appended.trailingSkippedBytes;
|
|
892
|
+
}
|
|
893
|
+
// Re-anchor the window in place. Rows that have fallen outside `size - maxReadBytes`
|
|
894
|
+
// are dropped using their recorded byte lengths, so the result is exactly the rows a
|
|
895
|
+
// fresh bounded read would load -- no superset, and truncatedPrefixBytes and
|
|
896
|
+
// snapshotWindow keep describing the read honestly. Refusing here instead would make
|
|
897
|
+
// this path dead code on any ledger past the window, which is precisely the case it
|
|
898
|
+
// exists for.
|
|
899
|
+
const windowStart = Math.max(0, size - maxReadBytes);
|
|
900
|
+
let entries = retained.entries.concat(appendedEntries);
|
|
901
|
+
// Skipped bytes trailing the retained rows sit BETWEEN them and the appended rows, so
|
|
902
|
+
// they belong to the first appended row's span. Folding them in keeps the recorded
|
|
903
|
+
// lengths summing to the true byte distance, which is what the trim walk relies on.
|
|
904
|
+
const joinedLengths = appendedLengths.slice();
|
|
905
|
+
if (retained.trailingSkippedBytes > 0 && joinedLengths.length > 0) {
|
|
906
|
+
joinedLengths[0] = joinedLengths[0]! + retained.trailingSkippedBytes;
|
|
907
|
+
}
|
|
908
|
+
let lengths = retained.entryLengths.concat(joinedLengths);
|
|
909
|
+
let rowsBeginAtBytes = retained.rowsBeginAtBytes;
|
|
910
|
+
// Byte-window truncation advances ONLY here, so it stays exactly what a cold read of
|
|
911
|
+
// this window reports. The entry cap below is entry-count truncation and must not
|
|
912
|
+
// move it -- the two are independent signals in the API.
|
|
913
|
+
let windowTruncatedBytes = retained.truncatedPrefixBytes;
|
|
914
|
+
let dropIndex = 0;
|
|
915
|
+
while (dropIndex < lengths.length && rowsBeginAtBytes < windowStart) {
|
|
916
|
+
rowsBeginAtBytes += lengths[dropIndex]!;
|
|
917
|
+
windowTruncatedBytes += lengths[dropIndex]!;
|
|
918
|
+
dropIndex += 1;
|
|
919
|
+
}
|
|
920
|
+
// If every row is gone and a trailing unparseable remainder still sits before the
|
|
921
|
+
// window start, nothing is left to advance the offset with: the retained span would
|
|
922
|
+
// keep growing past maxReadBytes on each malformed-only append while the accounting
|
|
923
|
+
// still balanced. Re-anchor with a full read instead.
|
|
924
|
+
if (rowsBeginAtBytes < windowStart) return null;
|
|
925
|
+
if (dropIndex > 0) {
|
|
926
|
+
entries = entries.slice(dropIndex);
|
|
927
|
+
lengths = lengths.slice(dropIndex);
|
|
928
|
+
}
|
|
929
|
+
let entriesDropped = retained.entriesDropped + appendedDropped;
|
|
930
|
+
if (entries.length > MANAGEMENT_USAGE_MAX_ENTRIES) {
|
|
931
|
+
// A cold read applies the entry cap to the whole window and reports byte
|
|
932
|
+
// truncation for the window boundary alone. An incremental read arrives at the cap
|
|
933
|
+
// by a different route and cannot reconstruct that ordering from retained state, so
|
|
934
|
+
// continuing here would report a truncatedPrefixBytes that disagrees with a fresh
|
|
935
|
+
// read of the same window. Re-anchor instead.
|
|
936
|
+
//
|
|
937
|
+
// This is reachable in production, not a theoretical branch: rows average ~118
|
|
938
|
+
// bytes on a real ledger, so 500,000 of them occupy ~56 MiB and fit inside the
|
|
939
|
+
// 64 MiB window. Both truncations can therefore apply at once.
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
// The recorded lengths plus the trailing remainder must account for every byte from
|
|
943
|
+
// the retained rows' start to EOF; if they do not, the lengths and the file have
|
|
944
|
+
// diverged and the retained rows cannot be trusted.
|
|
945
|
+
let accounted = appendedTrailingSkipped;
|
|
946
|
+
for (const length of lengths) accounted += length;
|
|
947
|
+
if (rowsBeginAtBytes + accounted !== size) return null;
|
|
948
|
+
usageReadCacheStats.tailReads += 1;
|
|
949
|
+
// Byte-window truncation is what the API reports, and it stays independent of
|
|
950
|
+
// entry-count truncation. A cold read reports the record boundary it actually landed
|
|
951
|
+
// on, which is where the rows begin MINUS whatever the entry cap removed -- the cap
|
|
952
|
+
// is not window truncation. When nothing was skipped by the window at all, a cold
|
|
953
|
+
// read reports 0.
|
|
954
|
+
const truncatedPrefixBytes = windowTruncatedBytes;
|
|
955
|
+
return {
|
|
956
|
+
entries,
|
|
957
|
+
revision,
|
|
958
|
+
truncatedPrefixBytes,
|
|
959
|
+
// ENTRY-count truncation only. Byte-window truncation is reported by
|
|
960
|
+
// truncatedPrefixBytes, and the route ORs the two itself; folding bytes in here
|
|
961
|
+
// would make a byte-truncated read claim rows were dropped when none were.
|
|
962
|
+
entriesTruncated: entriesDropped > 0,
|
|
963
|
+
entriesDropped,
|
|
964
|
+
// The digest must describe exactly the region the returned rows came from, which
|
|
965
|
+
// is the post-trim window, not the pre-trim one.
|
|
966
|
+
prefixDigest: usageRegionDigest(fd, rowsBeginAtBytes, size) ?? "",
|
|
967
|
+
entryLengths: lengths,
|
|
968
|
+
trailingSkippedBytes: appendedTrailingSkipped,
|
|
969
|
+
rowsBeginAtBytes,
|
|
593
970
|
};
|
|
594
971
|
} finally {
|
|
595
972
|
if (fd !== undefined) closeSync(fd);
|
|
@@ -598,8 +975,10 @@ async function readUsageEntriesFullCooperatively(
|
|
|
598
975
|
|
|
599
976
|
/**
|
|
600
977
|
* Management API reader: full parses yield between bounded batches and concurrent
|
|
601
|
-
* callers share work
|
|
602
|
-
*
|
|
978
|
+
* callers share work when they observe the same ledger identity and byte window.
|
|
979
|
+
* Appends keep that identity; replacements (inode/birthtime change) start a new flight.
|
|
980
|
+
* The parsed tail is retained under the app-owned memory budget so an append reparses
|
|
981
|
+
* only the appended bytes; the retained rows are evictable and are copied per caller.
|
|
603
982
|
*/
|
|
604
983
|
export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_USAGE_MAX_READ_BYTES): Promise<{
|
|
605
984
|
entries: PersistedUsageEntry[];
|
|
@@ -612,18 +991,57 @@ export async function readUsageSnapshotForManagement(maxReadBytes = MANAGEMENT_U
|
|
|
612
991
|
const path = usageLogPath();
|
|
613
992
|
if (!existsSync(path)) return { entries: [], revision: null, truncatedPrefixBytes: 0, entriesTruncated: false, entriesDropped: 0 };
|
|
614
993
|
const observed = currentUsageLogRevision();
|
|
615
|
-
const key = `${
|
|
994
|
+
const key = `${usageLogIdentityKey(observed)}\0${maxReadBytes}`;
|
|
995
|
+
const observedSize = observed?.size ?? 0;
|
|
616
996
|
const existing = managementUsageReadInflight;
|
|
617
|
-
|
|
997
|
+
const replacement = Boolean(existing && observedSize < existing.openedSize);
|
|
998
|
+
if (!replacement && existing?.key === key && Date.now() - existing.startedAt <= MANAGEMENT_USAGE_FLIGHT_STALE_MS) {
|
|
999
|
+
const shared = await existing.promise;
|
|
1000
|
+
return { ...shared, entries: shared.entries.slice() };
|
|
1001
|
+
}
|
|
1002
|
+
if (existing && (existing.key !== key || replacement || Date.now() - existing.startedAt > MANAGEMENT_USAGE_FLIGHT_STALE_MS)) {
|
|
1003
|
+
existing.abort.abort(new Error("management usage read superseded"));
|
|
1004
|
+
} else if (existing) {
|
|
618
1005
|
const shared = await existing.promise;
|
|
619
1006
|
return { ...shared, entries: shared.entries.slice() };
|
|
620
1007
|
}
|
|
621
|
-
existing?.abort.abort(new Error("management usage read superseded"));
|
|
622
1008
|
const abort = new AbortController();
|
|
623
|
-
const
|
|
624
|
-
|
|
1009
|
+
const retained = retainedUsageSnapshot;
|
|
1010
|
+
const reusable = retained
|
|
1011
|
+
&& retained.identityKey === usageLogIdentityKey(observed)
|
|
1012
|
+
&& retained.maxReadBytes === maxReadBytes
|
|
1013
|
+
? retained
|
|
1014
|
+
: null;
|
|
1015
|
+
const promise = (async (): Promise<ManagementUsageSnapshot> => {
|
|
1016
|
+
if (reusable) {
|
|
1017
|
+
const incremental = await readUsageEntriesIncrementally(path, abort.signal, maxReadBytes, reusable);
|
|
1018
|
+
if (incremental) return incremental;
|
|
1019
|
+
// The retained rows could not be extended safely; drop them before the full read
|
|
1020
|
+
// so a stale window is never combined with freshly parsed bytes.
|
|
1021
|
+
discardRetainedUsageSnapshot();
|
|
1022
|
+
}
|
|
1023
|
+
return readUsageEntriesFullCooperatively(path, abort.signal, maxReadBytes);
|
|
1024
|
+
})();
|
|
1025
|
+
managementUsageReadInflight = { key, openedSize: observedSize, promise, startedAt: Date.now(), abort };
|
|
625
1026
|
try {
|
|
626
1027
|
const snapshot = await promise;
|
|
1028
|
+
retainedUsageSnapshot = {
|
|
1029
|
+
identityKey: usageLogIdentityKey(snapshot.revision),
|
|
1030
|
+
maxReadBytes,
|
|
1031
|
+
coveredThroughBytes: snapshot.revision.size,
|
|
1032
|
+
prefixDigest: snapshot.prefixDigest,
|
|
1033
|
+
truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
|
|
1034
|
+
entryLengths: snapshot.entryLengths,
|
|
1035
|
+
trailingSkippedBytes: snapshot.trailingSkippedBytes,
|
|
1036
|
+
rowsBeginAtBytes: snapshot.rowsBeginAtBytes,
|
|
1037
|
+
entries: snapshot.entries,
|
|
1038
|
+
entriesTruncated: snapshot.entriesTruncated,
|
|
1039
|
+
entriesDropped: snapshot.entriesDropped,
|
|
1040
|
+
revision: snapshot.revision,
|
|
1041
|
+
retainedAt: Date.now(),
|
|
1042
|
+
approxBytes: retainedUsageSnapshotBytes(snapshot.entries),
|
|
1043
|
+
};
|
|
1044
|
+
enforceAppOwnedMemoryBudget();
|
|
627
1045
|
return { ...snapshot, entries: snapshot.entries.slice() };
|
|
628
1046
|
} finally {
|
|
629
1047
|
if (managementUsageReadInflight?.promise === promise) managementUsageReadInflight = null;
|
package/src/vision/index.ts
CHANGED
|
@@ -11,6 +11,11 @@ import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
|
11
11
|
import type { SidecarOutcomeRecorder } from "../web-search/executor";
|
|
12
12
|
import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
|
|
13
13
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_VISION_TIMEOUT_MS,
|
|
16
|
+
MAX_VISION_TIMEOUT_MS,
|
|
17
|
+
MIN_VISION_TIMEOUT_MS,
|
|
18
|
+
} from "./timeout-bounds";
|
|
14
19
|
|
|
15
20
|
export { describeImage } from "./describe";
|
|
16
21
|
export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
|
|
@@ -23,12 +28,16 @@ export {
|
|
|
23
28
|
visionEligibleModelOptions,
|
|
24
29
|
} from "./eligibility";
|
|
25
30
|
export type { VisionCandidateModel, VisionModelOption, VisionSidecarBackend } from "./eligibility";
|
|
31
|
+
export {
|
|
32
|
+
DEFAULT_VISION_TIMEOUT_MS,
|
|
33
|
+
MAX_VISION_TIMEOUT_MS,
|
|
34
|
+
MIN_VISION_TIMEOUT_MS,
|
|
35
|
+
};
|
|
26
36
|
|
|
27
37
|
const DEFAULT_VISION_MODEL = "gpt-5.4-mini";
|
|
28
38
|
const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5";
|
|
29
|
-
const DEFAULT_TIMEOUT_MS = 45_000;
|
|
30
39
|
const DEFAULT_REASONING: VisionReasoningEffort = "low";
|
|
31
|
-
const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
|
|
40
|
+
export const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8;
|
|
32
41
|
const DESCRIPTION_CACHE_MAX_ENTRIES = 256;
|
|
33
42
|
export const VISION_DESCRIPTION_CACHE_MAX_BYTES = 1024 * 1024;
|
|
34
43
|
const descriptionEncoder = new TextEncoder();
|
|
@@ -154,6 +163,18 @@ export function resolveMaxDescriptionsPerTurn(value: unknown): number {
|
|
|
154
163
|
: DEFAULT_MAX_DESCRIPTIONS_PER_TURN;
|
|
155
164
|
}
|
|
156
165
|
|
|
166
|
+
export function isValidVisionTimeoutMs(value: unknown): value is number {
|
|
167
|
+
return typeof value === "number"
|
|
168
|
+
&& Number.isInteger(value)
|
|
169
|
+
&& value >= MIN_VISION_TIMEOUT_MS
|
|
170
|
+
&& value <= MAX_VISION_TIMEOUT_MS;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Runtime config is permissive: malformed or out-of-range values fall back to the default. */
|
|
174
|
+
export function resolveVisionTimeoutMs(value: unknown): number {
|
|
175
|
+
return isValidVisionTimeoutMs(value) ? value : DEFAULT_VISION_TIMEOUT_MS;
|
|
176
|
+
}
|
|
177
|
+
|
|
157
178
|
/** Run `worker` over `items` with bounded concurrency, preserving input order in the result array. */
|
|
158
179
|
async function runBounded<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
|
|
159
180
|
const results = new Array<R>(items.length);
|
|
@@ -271,7 +292,7 @@ export function planVisionSidecar(
|
|
|
271
292
|
settings: {
|
|
272
293
|
model,
|
|
273
294
|
reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING,
|
|
274
|
-
timeoutMs: cfg.timeoutMs
|
|
295
|
+
timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs),
|
|
275
296
|
},
|
|
276
297
|
maxDescriptionsPerTurn,
|
|
277
298
|
};
|
|
@@ -284,7 +305,7 @@ export function planVisionSidecar(
|
|
|
284
305
|
settings: {
|
|
285
306
|
model,
|
|
286
307
|
reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING,
|
|
287
|
-
|
|
308
|
+
timeoutMs: resolveVisionTimeoutMs(cfg.timeoutMs),
|
|
288
309
|
},
|
|
289
310
|
maxDescriptionsPerTurn,
|
|
290
311
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inclusive integer bounds for `visionSidecar.timeoutMs`.
|
|
3
|
+
* The ceiling is the 32-bit timer delay used by `signalWithTimeout` / `setTimeout`.
|
|
4
|
+
* This module is the single authority: the runtime, management API, and Dashboard
|
|
5
|
+
* import these numbers rather than restating them.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_VISION_TIMEOUT_MS = 45_000;
|
|
8
|
+
export const MIN_VISION_TIMEOUT_MS = 1;
|
|
9
|
+
export const MAX_VISION_TIMEOUT_MS = 2_147_483_647;
|