@bitkyc08/opencodex 2.7.27 → 2.7.28

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.
@@ -0,0 +1,223 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { resolveCodexHomeDir } from "../codex/home";
5
+
6
+ /**
7
+ * Read-only CODEX_HOME storage scanner — Phase 1 of the Storage page epic
8
+ * (devlog/_plan/500_storage-page-session-cleanup). Pure measurement: sizes via
9
+ * fs.stat walks, DB row counts via short-timeout readonly opens that degrade to
10
+ * null on lock/corruption. Performs zero writes under CODEX_HOME.
11
+ */
12
+
13
+ export type StorageBucketKey =
14
+ | "sessions"
15
+ | "archived_sessions"
16
+ | "logs_db"
17
+ | "state_db"
18
+ | "attachments"
19
+ | "deletion_manifests"
20
+ | "other";
21
+
22
+ export interface StorageLargestEntry {
23
+ /** Path relative to CODEX_HOME, forward-slash separated on every platform. */
24
+ path: string;
25
+ bytes: number;
26
+ }
27
+
28
+ export interface StorageBucket {
29
+ key: StorageBucketKey;
30
+ label: string;
31
+ bytes: number;
32
+ fileCount: number;
33
+ /** Epoch ms of the oldest/newest file mtime; absent for empty buckets. */
34
+ oldest?: number;
35
+ newest?: number;
36
+ largest?: StorageLargestEntry[];
37
+ /** sqlite buckets only: row count from the newest versioned DB, null when locked/unreadable. */
38
+ rows?: number | null;
39
+ }
40
+
41
+ export interface StorageReport {
42
+ codexHome: string;
43
+ generatedAt: number;
44
+ total: { bytes: number; fileCount: number };
45
+ buckets: StorageBucket[];
46
+ }
47
+
48
+ const LARGEST_CAP = 5;
49
+
50
+ const BUCKET_LABELS: Record<StorageBucketKey, string> = {
51
+ sessions: "Active sessions",
52
+ archived_sessions: "Archived sessions",
53
+ logs_db: "Logs database",
54
+ state_db: "State database",
55
+ attachments: "Attachments",
56
+ deletion_manifests: "Deletion manifests",
57
+ other: "Other",
58
+ };
59
+
60
+ /** Dirs under CODEX_HOME that map to a dedicated bucket; anything else is "other". */
61
+ const DIR_BUCKETS: Record<string, StorageBucketKey> = {
62
+ sessions: "sessions",
63
+ archived_sessions: "archived_sessions",
64
+ attachments: "attachments",
65
+ deletion_manifests: "deletion_manifests",
66
+ };
67
+
68
+ // state_5.sqlite / logs_2.sqlite carry a version suffix and live WAL/SHM siblings.
69
+ const STATE_DB_FILE = /^state_(\d+)\.sqlite(-wal|-shm)?$/;
70
+ const LOGS_DB_FILE = /^logs_(\d+)\.sqlite(-wal|-shm)?$/;
71
+
72
+ interface FileEntry {
73
+ relPath: string;
74
+ bytes: number;
75
+ mtimeMs: number;
76
+ }
77
+
78
+ /** Recursive fs.stat walk. Unreadable entries (races, broken symlinks) are skipped, never fatal. */
79
+ function walkFiles(dir: string, relPrefix: string, out: FileEntry[]): void {
80
+ let entries;
81
+ try {
82
+ entries = readdirSync(dir, { withFileTypes: true });
83
+ } catch {
84
+ return;
85
+ }
86
+ for (const entry of entries) {
87
+ const full = join(dir, entry.name);
88
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
89
+ try {
90
+ if (entry.isDirectory()) {
91
+ walkFiles(full, relPath, out);
92
+ } else if (entry.isFile()) {
93
+ const stat = statSync(full);
94
+ out.push({ relPath, bytes: stat.size, mtimeMs: stat.mtimeMs });
95
+ }
96
+ } catch {
97
+ /* entry vanished mid-scan — diagnostics tolerate racy trees */
98
+ }
99
+ }
100
+ }
101
+
102
+ function buildBucket(key: StorageBucketKey, files: FileEntry[]): StorageBucket {
103
+ const bucket: StorageBucket = {
104
+ key,
105
+ label: BUCKET_LABELS[key],
106
+ bytes: 0,
107
+ fileCount: files.length,
108
+ };
109
+ for (const file of files) {
110
+ bucket.bytes += file.bytes;
111
+ if (bucket.oldest === undefined || file.mtimeMs < bucket.oldest) bucket.oldest = file.mtimeMs;
112
+ if (bucket.newest === undefined || file.mtimeMs > bucket.newest) bucket.newest = file.mtimeMs;
113
+ }
114
+ if (files.length > 0) {
115
+ bucket.largest = [...files]
116
+ .sort((a, b) => b.bytes - a.bytes)
117
+ .slice(0, LARGEST_CAP)
118
+ .map(f => ({ path: f.relPath, bytes: f.bytes }));
119
+ }
120
+ return bucket;
121
+ }
122
+
123
+ /**
124
+ * Row count via a lock-safe readonly open (the same secondary-reader contract as
125
+ * codex/history-provider.ts): short busy timeout, and any lock/corruption/schema
126
+ * error degrades to null — "unknown", never a crash and never a write.
127
+ */
128
+ function countRowsReadonly(dbPath: string, table: string): number | null {
129
+ try {
130
+ const db = new Database(dbPath, { readonly: true });
131
+ try {
132
+ db.exec("PRAGMA busy_timeout = 100");
133
+ const row = db.query<{ n: number }, []>(`SELECT count(*) AS n FROM "${table}"`).get();
134
+ return row?.n ?? null;
135
+ } finally {
136
+ db.close();
137
+ }
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /** Newest versioned DB main file (e.g. state_5.sqlite over state_4.sqlite), or null. */
144
+ function newestVersionedDb(names: string[], pattern: RegExp): string | null {
145
+ let best: string | null = null;
146
+ let bestVersion = -1;
147
+ for (const name of names) {
148
+ const match = name.match(pattern);
149
+ if (!match || match[2]) continue; // -wal/-shm siblings never win
150
+ const version = Number(match[1]);
151
+ if (version > bestVersion) {
152
+ bestVersion = version;
153
+ best = name;
154
+ }
155
+ }
156
+ return best;
157
+ }
158
+
159
+ export function scanStorage(codexHome: string = resolveCodexHomeDir()): StorageReport {
160
+ const files: Record<StorageBucketKey, FileEntry[]> = {
161
+ sessions: [],
162
+ archived_sessions: [],
163
+ logs_db: [],
164
+ state_db: [],
165
+ attachments: [],
166
+ deletion_manifests: [],
167
+ other: [],
168
+ };
169
+
170
+ let rootNames: string[] = [];
171
+ try {
172
+ rootNames = readdirSync(codexHome);
173
+ } catch (error) {
174
+ // A missing home is a normal fresh-machine state — report zeros. Anything else
175
+ // (e.g. ENOTDIR: CODEX_HOME points at a file) is a broken setup the caller
176
+ // must surface as a scan failure, not silently render as an empty home.
177
+ const code = (error as NodeJS.ErrnoException).code;
178
+ if (code !== "ENOENT") throw error;
179
+ }
180
+
181
+ for (const name of rootNames) {
182
+ const full = join(codexHome, name);
183
+ let stat;
184
+ try {
185
+ stat = statSync(full);
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (stat.isDirectory()) {
190
+ walkFiles(full, name, files[DIR_BUCKETS[name] ?? "other"]);
191
+ } else if (stat.isFile()) {
192
+ const key: StorageBucketKey = STATE_DB_FILE.test(name) ? "state_db" : LOGS_DB_FILE.test(name) ? "logs_db" : "other";
193
+ files[key].push({ relPath: name, bytes: stat.size, mtimeMs: stat.mtimeMs });
194
+ }
195
+ }
196
+
197
+ const buckets = (Object.keys(files) as StorageBucketKey[]).map(key => buildBucket(key, files[key]));
198
+
199
+ const stateDbName = newestVersionedDb(rootNames, STATE_DB_FILE);
200
+ const stateBucket = buckets.find(b => b.key === "state_db");
201
+ if (stateBucket && stateBucket.fileCount > 0) {
202
+ stateBucket.rows = stateDbName ? countRowsReadonly(join(codexHome, stateDbName), "threads") : null;
203
+ }
204
+ const logsDbName = newestVersionedDb(rootNames, LOGS_DB_FILE);
205
+ const logsBucket = buckets.find(b => b.key === "logs_db");
206
+ if (logsBucket && logsBucket.fileCount > 0) {
207
+ logsBucket.rows = logsDbName ? countRowsReadonly(join(codexHome, logsDbName), "logs") : null;
208
+ }
209
+
210
+ let totalBytes = 0;
211
+ let totalFiles = 0;
212
+ for (const bucket of buckets) {
213
+ totalBytes += bucket.bytes;
214
+ totalFiles += bucket.fileCount;
215
+ }
216
+
217
+ return {
218
+ codexHome,
219
+ generatedAt: Date.now(),
220
+ total: { bytes: totalBytes, fileCount: totalFiles },
221
+ buckets,
222
+ };
223
+ }
package/src/types.ts CHANGED
@@ -430,7 +430,10 @@ export interface OcxConfig {
430
430
  * those are unset — Bun's fetch honors them for all outbound calls; localhost is excluded.
431
431
  */
432
432
  proxy?: string;
433
- /** Upstream stall timeout (seconds). After this many seconds of no upstream data, emits response.incomplete. Default 90. Min 1. */
433
+ /**
434
+ * Upstream stall timeout (seconds). After this many seconds of no upstream data, emits
435
+ * response.incomplete. Default 300. Min 1.
436
+ */
434
437
  stallTimeoutSec?: number;
435
438
  /** Connect timeout (ms) for upstream fetch — covers DNS, TCP, TLS, and response header. Default 200000. */
436
439
  connectTimeoutMs?: number;
Binary file
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Expected-price overlay for models whose jawcode cost rows are missing or all-zero
3
+ * (subscription/OAuth surfaces). Sourced from official pricing pages only
4
+ * (devlog/_plan/260720_toks_speed_price_columns/003 — Luna research, main-verified).
5
+ *
6
+ * Status semantics:
7
+ * - "verified": official page opened directly; the 4-tuple is the published API price.
8
+ * - "verified-derived": mapped from a verified base-model price (for example an
9
+ * effort-suffix variant); propagates `estimated=true` downstream.
10
+ * - "unverified": research lead only. NEVER registered here and never returned by
11
+ * the resolver — unverified prices live in the 003 §5 backlog until promoted.
12
+ */
13
+
14
+ export interface Cost4 {
15
+ input: number;
16
+ output: number;
17
+ cacheRead: number;
18
+ cacheWrite: number;
19
+ }
20
+
21
+ export type ExpectedPriceStatus = "verified" | "verified-derived" | "unverified";
22
+
23
+ export interface ExpectedPriceOverlay {
24
+ provider: string;
25
+ modelId: string;
26
+ cost4: Cost4;
27
+ source: string;
28
+ verifiedAt: string;
29
+ status: ExpectedPriceStatus;
30
+ }
31
+
32
+ const GEMINI_31_PRO: Cost4 = { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 };
33
+ const GEMINI_35_FLASH: Cost4 = { input: 1.5, output: 9, cacheRead: 0.15, cacheWrite: 0 };
34
+ const GEMINI_3_FLASH: Cost4 = { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 };
35
+ const MINIMAX_M21_HIGHSPEED: Cost4 = { input: 0.6, output: 2.4, cacheRead: 0.03, cacheWrite: 0.375 };
36
+ const KIMI_K3: Cost4 = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3 };
37
+ const KIMI_K27_CODE: Cost4 = { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0.95 };
38
+ const KIMI_K27_CODE_HIGHSPEED: Cost4 = { input: 1.9, output: 8, cacheRead: 0.38, cacheWrite: 1.9 };
39
+ const KIMI_K26: Cost4 = { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0.95 };
40
+ const KIMI_K25: Cost4 = { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0.6 };
41
+
42
+ const GEMINI_PRICING = "https://ai.google.dev/gemini-api/docs/pricing (2026-06-18); cacheWrite=0: storage is billed per-hour, not per-token";
43
+ const MINIMAX_PRICING = "https://platform.minimax.io/docs/guides/pricing-paygo";
44
+ const DEEPSEEK_PRICING = "https://api-docs.deepseek.com/quick_start/pricing-details-usd; V4 Flash alias transition scheduled 2026-07-24 — re-verify after";
45
+ // Kimi official tables publish input/output/cache-hit only; cacheWrite is mapped to the
46
+ // cache-miss input price (Kimi auto-caches with no separate write billing). 2026-07-20 re-verified.
47
+ const KIMI_PRICING = "https://platform.kimi.ai/docs/pricing (official table; cacheWrite derived = input, Kimi auto-cache has no write billing)";
48
+
49
+ export const EXPECTED_PRICE_OVERLAYS: readonly ExpectedPriceOverlay[] = [
50
+ // MiniMax M2.1 highspeed — published PAYG price (verified).
51
+ { provider: "minimax", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" },
52
+ { provider: "minimax-cn", modelId: "MiniMax-M2.1-highspeed", cost4: MINIMAX_M21_HIGHSPEED, source: MINIMAX_PRICING, verifiedAt: "2026-07-20", status: "verified" },
53
+ // DeepSeek current-generation IDs (verified; cache-hit price mapped to cacheRead).
54
+ { provider: "deepseek", modelId: "deepseek-chat", cost4: { input: 0.27, output: 1.1, cacheRead: 0.07, cacheWrite: 0 }, source: DEEPSEEK_PRICING, verifiedAt: "2026-07-20", status: "verified" },
55
+ { provider: "deepseek", modelId: "deepseek-reasoner", cost4: { input: 0.55, output: 2.19, cacheRead: 0.14, cacheWrite: 0 }, source: DEEPSEEK_PRICING, verifiedAt: "2026-07-20", status: "verified" },
56
+ // Google Antigravity effort-suffix variants — derived from the verified base-model
57
+ // price (Google does not publish per-suffix prices; Agent inference bills at the
58
+ // base model's standard rate per the official Billing FAQ).
59
+ { provider: "google-antigravity", modelId: "gemini-3.1-pro-low", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
60
+ { provider: "google-antigravity", modelId: "gemini-3.1-pro-high", cost4: GEMINI_31_PRO, source: `derived: gemini-3.1-pro (<=200k tier) ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
61
+ { provider: "google-antigravity", modelId: "gemini-3.5-flash-extra-low", cost4: GEMINI_35_FLASH, source: `derived: gemini-3.5-flash ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
62
+ { provider: "google-antigravity", modelId: "gemini-3.5-flash-low", cost4: GEMINI_35_FLASH, source: `derived: gemini-3.5-flash ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
63
+ { provider: "google-antigravity", modelId: "gemini-3.5-flash-mid", cost4: GEMINI_35_FLASH, source: `derived: gemini-3.5-flash ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
64
+ { provider: "google-antigravity", modelId: "gemini-3.5-flash-high", cost4: GEMINI_35_FLASH, source: `derived: gemini-3.5-flash ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
65
+ { provider: "google-antigravity", modelId: "gemini-3-flash-agent", cost4: GEMINI_3_FLASH, source: `derived: gemini-3-flash + Agent billing principle ${GEMINI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
66
+ // Google Vertex/Gemini API current model (verified — published table).
67
+ { provider: "google-antigravity", modelId: "gemini-3.1-pro-preview", cost4: GEMINI_31_PRO, source: GEMINI_PRICING, verifiedAt: "2026-07-20", status: "verified" },
68
+ // Antigravity-bundled third-party models — derived from the underlying vendor's
69
+ // official API price (Antigravity itself bills via subscription quota).
70
+ { provider: "google-antigravity", modelId: "claude-sonnet-4-6", cost4: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, source: "derived: anthropic official https://platform.claude.com/docs/en/about-claude/pricing (5m cache-write; 1h is $6)", verifiedAt: "2026-07-20", status: "verified-derived" },
71
+ { provider: "google-antigravity", modelId: "claude-opus-4-6-thinking", cost4: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, source: "derived: anthropic official https://platform.claude.com/docs/en/about-claude/pricing (5m cache-write; 1h is $10)", verifiedAt: "2026-07-20", status: "verified-derived" },
72
+ { provider: "google-antigravity", modelId: "gpt-oss-120b-medium", cost4: { input: 0.03, output: 0.15, cacheRead: 0, cacheWrite: 0 }, source: "derived: gpt-oss-120b open-weights — OpenRouter advertised lowest https://openrouter.ai/openai/gpt-oss-120b/providers", verifiedAt: "2026-07-20", status: "verified-derived" },
73
+ // Kimi / Moonshot — official price tables are now published (2026-07-20 re-check;
74
+ // previously empty). kimi = Kimi Code OAuth surface, moonshot = CN key surface,
75
+ // kimi-code = API key surface (expected list price, not actual billing).
76
+ { provider: "kimi", modelId: "k3", cost4: KIMI_K3, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
77
+ { provider: "kimi", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: k3 (official docs: k3[1m] is the 1M-context compat notation for k3) ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
78
+ { provider: "kimi", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
79
+ { provider: "kimi", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
80
+ { provider: "kimi", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
81
+ { provider: "kimi", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
82
+ { provider: "kimi", modelId: "kimi-for-coding", cost4: KIMI_K27_CODE, source: `derived: kimi-k2.7-code (Kimi Code maps to K2.7 Code per official model docs) ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
83
+ { provider: "moonshot", modelId: "kimi-k3", cost4: KIMI_K3, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
84
+ { provider: "moonshot", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
85
+ { provider: "moonshot", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
86
+ { provider: "moonshot", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
87
+ { provider: "moonshot", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
88
+ { provider: "kimi-code", modelId: "k3", cost4: KIMI_K3, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
89
+ { provider: "kimi-code", modelId: "k3[1m]", cost4: KIMI_K3, source: `derived: k3 ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
90
+ { provider: "kimi-code", modelId: "kimi-k2.7-code", cost4: KIMI_K27_CODE, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
91
+ { provider: "kimi-code", modelId: "kimi-k2.7-code-highspeed", cost4: KIMI_K27_CODE_HIGHSPEED, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
92
+ { provider: "kimi-code", modelId: "kimi-k2.6", cost4: KIMI_K26, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
93
+ { provider: "kimi-code", modelId: "kimi-k2.5", cost4: KIMI_K25, source: KIMI_PRICING, verifiedAt: "2026-07-20", status: "verified-derived" },
94
+ { provider: "kimi-code", modelId: "kimi-for-coding", cost4: KIMI_K27_CODE, source: `derived: kimi-k2.7-code ${KIMI_PRICING}`, verifiedAt: "2026-07-20", status: "verified-derived" },
95
+ // Cursor Auto router — Cursor's published fixed token price (verified).
96
+ { provider: "cursor", modelId: "auto", cost4: { input: 1.25, output: 6, cacheRead: 0.25, cacheWrite: 1.25 }, source: "https://docs.cursor.com/account/pricing + https://cursor.com/blog/aug-2025-pricing", verifiedAt: "2026-07-20", status: "verified" },
97
+ ];
98
+
99
+ /**
100
+ * Exact-key overlay lookup. Returns verified first, then verified-derived.
101
+ * NEVER returns "unverified" rows — fail-closed is enforced in code, not just docs.
102
+ * No fuzzy / case-fold / wire-model fallback.
103
+ */
104
+ export function findExpectedPriceOverlay(
105
+ provider: string,
106
+ modelId: string,
107
+ overlays: readonly ExpectedPriceOverlay[] = EXPECTED_PRICE_OVERLAYS,
108
+ ): ExpectedPriceOverlay | undefined {
109
+ const exact = overlays.filter(row => row.provider === provider && row.modelId === modelId);
110
+ return exact.find(row => row.status === "verified")
111
+ ?? exact.find(row => row.status === "verified-derived");
112
+ }
package/src/usage/log.ts CHANGED
@@ -20,6 +20,8 @@ export interface PersistedUsageAttempt {
20
20
  adapter: string;
21
21
  status: number;
22
22
  durationMs: number;
23
+ /** TTFT relative to THIS attempt's start (WP4); unset for non-streaming/tool-only. */
24
+ firstOutputMs?: number;
23
25
  sendCount: number;
24
26
  recoveryKinds: AttemptRecoveryKind[];
25
27
  usageStatus: UsageStatus;
@@ -39,6 +41,8 @@ export interface PersistedUsageEntry {
39
41
  requestedModel?: string;
40
42
  status: number;
41
43
  durationMs: number;
44
+ /** TTFT relative to the request start (WP4); unset for non-streaming/tool-only. */
45
+ firstOutputMs?: number;
42
46
  usageStatus: UsageStatus;
43
47
  usage?: OcxUsage;
44
48
  totalTokens?: number;
@@ -151,6 +155,8 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
151
155
  }
152
156
  if ("inputTokenEstimate" in attempt
153
157
  && !isNonNegativeFiniteNumber(attempt.inputTokenEstimate)) return null;
158
+ if ("firstOutputMs" in attempt
159
+ && !isNonNegativeFiniteNumber(attempt.firstOutputMs)) return null;
154
160
  if ("totalTokens" in attempt
155
161
  && !isNonNegativeFiniteNumber(attempt.totalTokens)) return null;
156
162
  const usage = "usage" in attempt ? normalizeAttemptUsage(attempt.usage) : undefined;
@@ -168,6 +174,9 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
168
174
  adapter: attempt.adapter,
169
175
  status: attempt.status,
170
176
  durationMs: attempt.durationMs,
177
+ ...(isNonNegativeFiniteNumber(attempt.firstOutputMs)
178
+ ? { firstOutputMs: attempt.firstOutputMs }
179
+ : {}),
171
180
  sendCount: attempt.sendCount as number,
172
181
  recoveryKinds,
173
182
  usageStatus: attempt.usageStatus as UsageStatus,
@@ -200,6 +209,9 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
200
209
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
201
210
  status: entry.status,
202
211
  durationMs: entry.durationMs,
212
+ ...(isNonNegativeFiniteNumber(entry.firstOutputMs)
213
+ ? { firstOutputMs: entry.firstOutputMs }
214
+ : {}),
203
215
  usageStatus: entry.usageStatus,
204
216
  ...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
205
217
  ...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
@@ -1,6 +1,7 @@
1
1
  import { baseProviderLabel } from "../providers/label";
2
2
  import { usageDisplayTotalTokens } from "./totals";
3
3
  import type { PersistedUsageEntry, UsageStatus } from "./log";
4
+ import { estimateComboCost, estimateRequestCost } from "./cost";
4
5
 
5
6
  export type UsageRange = "7d" | "30d" | "all";
6
7
  export type UsageSurface = "all" | "codex" | "claude";
@@ -21,6 +22,15 @@ export interface UsageSummaryTotals {
21
22
  reasoningOutputTokens: number;
22
23
  totalTokens: number;
23
24
  coverageRatio: number;
25
+ /** Display-time estimated cost in USD for the filtered window (WP6, devlog 004).
26
+ * Sums per-request estimateRequestCost / per-attempt combo costs; requests whose
27
+ * price is unmatched are excluded from the sum and counted separately. */
28
+ estimatedCostUsd: number;
29
+ pricedRequests: number;
30
+ /** Requests with usage but no matched price anywhere (excluded from the sum). */
31
+ unpricedRequests: number;
32
+ /** Requests whose usage itself is missing/unsupported, so no cost can be computed. */
33
+ unmeteredRequests: number;
24
34
  }
25
35
 
26
36
  export interface UsageDay {
@@ -127,6 +137,10 @@ function blankTotals(): UsageSummaryTotals {
127
137
  reasoningOutputTokens: 0,
128
138
  totalTokens: 0,
129
139
  coverageRatio: 0,
140
+ estimatedCostUsd: 0,
141
+ pricedRequests: 0,
142
+ unpricedRequests: 0,
143
+ unmeteredRequests: 0,
130
144
  };
131
145
  }
132
146
 
@@ -215,6 +229,26 @@ function finalizeCoverage(totals: UsageSummaryTotals): void {
215
229
  totals.coverageRatio = totals.requests === 0 ? 0 : totals.measuredRequests / totals.requests;
216
230
  }
217
231
 
232
+ function addEstimatedCost(
233
+ totals: UsageSummaryTotals,
234
+ entry: Pick<PersistedUsageEntry, "provider" | "model" | "usageStatus" | "usage" | "attempts">,
235
+ ): void {
236
+ if (entry.usageStatus === "unreported" || entry.usageStatus === "unsupported"
237
+ || (!entry.usage && !entry.attempts?.length)) {
238
+ totals.unmeteredRequests += 1;
239
+ return;
240
+ }
241
+ const estimate = entry.attempts?.length
242
+ ? estimateComboCost(entry.attempts)
243
+ : estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
244
+ if (!estimate) {
245
+ totals.unpricedRequests += 1;
246
+ return;
247
+ }
248
+ totals.pricedRequests += 1;
249
+ totals.estimatedCostUsd += estimate.cost.total;
250
+ }
251
+
218
252
  function buildDayGrid(range: UsageRange, since: number | null, now: number, entries: PersistedUsageEntry[]): UsageDay[] {
219
253
  const window = rangeWindow(range, now);
220
254
  const days = range === "all" ? dayCountForAllRange(entries, now) : window.days;
@@ -385,6 +419,7 @@ export function summarizeUsage(
385
419
  bumpStatus(totals, entry.usageStatus);
386
420
  totals.attemptCount += entry.attempts?.length ?? 1;
387
421
  addTokens(totals, entry);
422
+ addEstimatedCost(totals, entry);
388
423
  }
389
424
  finalizeCoverage(totals);
390
425
  return {
@@ -3,6 +3,7 @@ import { modelInList } from "../types";
3
3
  import type { SidecarSettings } from "./executor";
4
4
  import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
5
5
  import { getAccountSet } from "../oauth/store";
6
+ import { DEFAULT_STALL_TIMEOUT_SEC } from "../stall-timeout";
6
7
 
7
8
  export { runWithWebSearch } from "./loop";
8
9
  export { buildWebSearchTool, extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
@@ -18,8 +19,6 @@ const DEFAULT_MAX_SEARCHES = 3;
18
19
  const DEFAULT_TIMEOUT_MS = 200_000;
19
20
  const DEFAULT_ROUTED_MODEL_STALL_TIMEOUT_MS = 200_000;
20
21
  const MAX_ROUTED_MODEL_STALL_TIMEOUT_MS = 2_147_483_647;
21
- // Mirrors the bridge's stall default (bridge.ts `options?.stallTimeoutSec ?? 90`).
22
- const DEFAULT_STALL_TIMEOUT_SEC = 90;
23
22
  const STALL_MARGIN_SEC = 30;
24
23
 
25
24
  /**
@@ -47,7 +46,7 @@ function finiteCeil(value: number | undefined, fallback: number): number {
47
46
  * are individually bounded by the configured bridge stall, response-header connect timeout,
48
47
  * routed-model response-body inactivity timeout, or sidecar timeout. The stall deadline must cover
49
48
  * the largest unit plus a margin;
50
- * otherwise a legitimately slow search trips the bridge's 90s default upstream_stall_timeout and
49
+ * otherwise a legitimately slow search trips the bridge's default upstream_stall_timeout and
51
50
  * kills the whole turn. Stays finite so a genuine hang is still cut off.
52
51
  */
53
52
  export function webSearchStallTimeoutSec(
@@ -186,6 +186,8 @@ export interface WebSearchLoopDeps {
186
186
  * sidecar search, so a legitimately slow-but-progressing unit never trips the bridge watchdog.
187
187
  */
188
188
  stallTimeoutSec?: number;
189
+ /** One-shot TTFT callback: first non-empty model output observed (WP4). */
190
+ onFirstOutput?: () => void;
189
191
  /**
190
192
  * 429 key-failover hook: rotate the provider's active pool key and return a rebuilt adapter,
191
193
  * or null when the pool is exhausted (same semantics as the normal routed path).
@@ -550,6 +552,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
550
552
  ...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
551
553
  hideThinkingSummary: parsed.options.hideThinkingSummary,
552
554
  ...(deps.stallTimeoutSec !== undefined ? { stallTimeoutSec: deps.stallTimeoutSec } : {}),
555
+ ...(deps.onFirstOutput ? { onFirstOutput: deps.onFirstOutput } : {}),
553
556
  },
554
557
  );
555
558
  return new Response(sse, { headers: SSE_HEADERS });