minecodex 2.0.25 → 2.0.26
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/features/session-status/src/adapters/codex/session-reader.mjs +7 -2
- package/features/session-status/src/adapters/opencodex/usage-log.mjs +90 -0
- package/features/session-status/src/domain/session-stats.mjs +21 -7
- package/features/session-status/src/session-status.mjs +17 -3
- package/package.json +1 -1
|
@@ -4,8 +4,11 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { sessionFacts } from "./session-log.mjs";
|
|
7
|
+
import { OpenCodexUsageReader } from "../opencodex/usage-log.mjs";
|
|
7
8
|
import { createSessionState, applySessionStatusEvent, sessionStatusSnapshot } from "../../domain/session-stats.mjs";
|
|
8
9
|
|
|
10
|
+
const defaultUsageReader = new OpenCodexUsageReader();
|
|
11
|
+
|
|
9
12
|
const THREAD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
10
13
|
export function threadIdFromLocation(value) {
|
|
11
14
|
return String(value ?? "").match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? null;
|
|
@@ -35,11 +38,12 @@ async function findLogs(directory, threadId) {
|
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export class SessionStatusReader {
|
|
38
|
-
constructor({ sessionsDir = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sessions"), now = Date.now } = {}) {
|
|
41
|
+
constructor({ sessionsDir = path.join(process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex"), "sessions"), now = Date.now, ocxUsagePath } = {}) {
|
|
39
42
|
this.sessionsDir = sessionsDir;
|
|
40
43
|
this.now = now;
|
|
41
44
|
this.states = new Map();
|
|
42
45
|
this.missingChecks = new Map();
|
|
46
|
+
this.ocxUsage = ocxUsagePath === undefined ? defaultUsageReader : new OpenCodexUsageReader(ocxUsagePath);
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
retryAfterMs(threadId) {
|
|
@@ -101,6 +105,7 @@ export class SessionStatusReader {
|
|
|
101
105
|
events.sort((left, right) => (Date.parse(left.timestamp) || 0) - (Date.parse(right.timestamp) || 0));
|
|
102
106
|
for (const event of events) for (const fact of sessionFacts(event)) applySessionStatusEvent(state, fact);
|
|
103
107
|
this.states.set(threadId, state);
|
|
104
|
-
|
|
108
|
+
const measurements = await this.ocxUsage.measurements(threadId, state.speedMeasurements);
|
|
109
|
+
return sessionStatusSnapshot(state, threadId, measurements);
|
|
105
110
|
}
|
|
106
111
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
// OCX usage.jsonl 的数值投影;不读取配置、密钥或对话正文。
|
|
8
|
+
function usageRecord(row) {
|
|
9
|
+
if (typeof row.requestId !== "string" || typeof row.conversationId !== "string"
|
|
10
|
+
|| !Number.isFinite(row.timestamp) || !Number.isFinite(row.durationMs) || row.durationMs < 0) return null;
|
|
11
|
+
return { id:row.requestId, conversationId:row.conversationId, start:row.timestamp,
|
|
12
|
+
end:row.timestamp+row.durationMs, duration:row.durationMs,
|
|
13
|
+
first:row.firstOutputMs, generationStart:row.genStartMs, generationEnd:row.lastOutputMs,
|
|
14
|
+
usageStatus:row.usageStatus, input:row.usage?.inputTokens, cached:row.usage?.cachedInputTokens ?? 0,
|
|
15
|
+
output:row.usage?.outputTokens };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function decodeMeasurement(record) {
|
|
19
|
+
const unavailable = reason => ({source:"ocx",output:record.output,elapsed:null,reason});
|
|
20
|
+
if (record.usageStatus === "unsupported") return unavailable("usage_unsupported");
|
|
21
|
+
if (!Number.isFinite(record.output) || record.output <= 0) return unavailable("output_missing");
|
|
22
|
+
let elapsed;
|
|
23
|
+
if (Number.isFinite(record.generationStart) && Number.isFinite(record.generationEnd)) {
|
|
24
|
+
elapsed=record.generationEnd-record.generationStart;
|
|
25
|
+
} else {
|
|
26
|
+
if (record.first === undefined) return unavailable("ttft_missing");
|
|
27
|
+
if (!Number.isFinite(record.first) || record.first < 0) return unavailable("invalid_duration");
|
|
28
|
+
elapsed=record.duration-record.first;
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isFinite(elapsed) || elapsed <= 0) return unavailable("invalid_duration");
|
|
31
|
+
if (elapsed < 1000) return unavailable("decode_window_too_short");
|
|
32
|
+
return {source:"ocx",output:record.output,elapsed,reason:null};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class OpenCodexUsageReader {
|
|
36
|
+
constructor(filePath=path.join(process.env.OPENCODEX_HOME?.trim() || path.join(os.homedir(),".opencodex"),"usage.jsonl")) {
|
|
37
|
+
this.filePath=filePath;this.offset=0;this.pending="";this.identity=null;
|
|
38
|
+
this.conversations=new Map();this.refreshing=null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
refresh() {
|
|
42
|
+
if (!this.refreshing) this.refreshing=this.readAppend().finally(()=>{this.refreshing=null;});
|
|
43
|
+
return this.refreshing;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async readAppend() {
|
|
47
|
+
let file;
|
|
48
|
+
try { file=await stat(this.filePath); }
|
|
49
|
+
catch(error) {
|
|
50
|
+
if (!["ENOENT","EACCES","EPERM"].includes(error.code)) throw error;
|
|
51
|
+
this.offset=0;this.pending="";this.identity=null;this.conversations.clear();return;
|
|
52
|
+
}
|
|
53
|
+
const identity=`${file.dev}:${file.ino}`;
|
|
54
|
+
if (identity !== this.identity || file.size < this.offset) {
|
|
55
|
+
this.offset=0;this.pending="";this.conversations.clear();this.identity=identity;
|
|
56
|
+
}
|
|
57
|
+
if (file.size === this.offset) return;
|
|
58
|
+
const stream=createReadStream(this.filePath,{start:this.offset,end:file.size-1,encoding:"utf8"});
|
|
59
|
+
for await(const chunk of stream) {
|
|
60
|
+
const lines=(this.pending+chunk).split("\n");this.pending=lines.pop() ?? "";
|
|
61
|
+
for(const line of lines) {
|
|
62
|
+
let record;
|
|
63
|
+
try {record=usageRecord(JSON.parse(line));} catch {continue;}
|
|
64
|
+
if (!record) continue;
|
|
65
|
+
let records=this.conversations.get(record.conversationId);
|
|
66
|
+
if (!records) this.conversations.set(record.conversationId,records=new Map());
|
|
67
|
+
records.set(record.id,record);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.offset=file.size;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async measurements(threadId,samples) {
|
|
74
|
+
await this.refresh();
|
|
75
|
+
const conversationId=createHash("sha256").update(threadId).digest("hex").slice(0,32);
|
|
76
|
+
const records=[...(this.conversations.get(conversationId)?.values() ?? [])];
|
|
77
|
+
const candidates=new Map(),owners=new Map();
|
|
78
|
+
for(const sample of samples) {
|
|
79
|
+
// 同会话、完整用量相等且完成相邻才可关联;同一请求不能归属两个响应。
|
|
80
|
+
const matches=records.filter(record=>Number.isFinite(record.input) && Number.isFinite(record.output)
|
|
81
|
+
&& record.input === sample.input && record.cached === sample.cached && record.output === sample.output
|
|
82
|
+
&& record.start <= sample.end && Math.abs(record.end-sample.end) <= 2000);
|
|
83
|
+
if (matches.length !== 1) continue;
|
|
84
|
+
const record=matches[0];candidates.set(sample.id,record);
|
|
85
|
+
owners.set(record.id,(owners.get(record.id) ?? 0)+1);
|
|
86
|
+
}
|
|
87
|
+
return new Map([...candidates].filter(([,record])=>owners.get(record.id) === 1)
|
|
88
|
+
.map(([id,record])=>[id,decodeMeasurement(record)]));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -90,8 +90,9 @@ export function applySessionStatusEvent(state, event) {
|
|
|
90
90
|
if (event.id) {
|
|
91
91
|
state.responses.add(event.id);
|
|
92
92
|
const output=event.last?.output;
|
|
93
|
-
if (Number.isFinite(output) && output > 0 &&
|
|
94
|
-
state.speedMeasurements.push({ turnId:state.currentTurnId,start:state.firstOutputAt,end:event.end,output,
|
|
93
|
+
if (Number.isFinite(output) && output > 0 && Number.isFinite(event.end))
|
|
94
|
+
state.speedMeasurements.push({ id:event.id,turnId:state.currentTurnId,start:state.firstOutputAt,end:event.end,output,
|
|
95
|
+
input:event.last.input,cached:event.last.cached,
|
|
95
96
|
outputs:state.pendingOutputIntervals.slice(),tools:state.pendingToolIntervals.slice() });
|
|
96
97
|
}
|
|
97
98
|
reset();
|
|
@@ -102,27 +103,38 @@ export function applySessionStatusEvent(state, event) {
|
|
|
102
103
|
if (state.total || state.turns.size) state.updatedAt=event.at ?? state.updatedAt;
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
export function sessionStatusSnapshot(state, threadId) {
|
|
106
|
+
export function sessionStatusSnapshot(state, threadId, measurements = new Map()) {
|
|
106
107
|
let decodeTokens = 0;
|
|
107
108
|
let decodeMs = 0;
|
|
108
109
|
let speedSamples = 0;
|
|
109
110
|
const turnSpeeds = new Map();
|
|
111
|
+
let unavailableSource = "codex", unavailableReason = "timing_missing";
|
|
110
112
|
for (const sample of state.speedMeasurements) {
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
113
|
+
const measurement = measurements.get(sample.id);
|
|
114
|
+
const source = measurement?.source ?? "codex";
|
|
115
|
+
const elapsed = measurement ? measurement.elapsed : sample.start == null ? null
|
|
116
|
+
: sample.end - sample.start - boundedToolWaitMs(sample.tools, sample.start, sample.end, sample.outputs);
|
|
117
|
+
if (!sample.turnId) continue;
|
|
118
|
+
unavailableSource=source;
|
|
119
|
+
unavailableReason=measurement?.reason ?? (Number.isFinite(elapsed) && elapsed > 0 && elapsed < 1000
|
|
120
|
+
? "decode_window_too_short" : "timing_missing");
|
|
121
|
+
if (!Number.isFinite(elapsed) || elapsed < 1000) continue;
|
|
122
|
+
const turn = turnSpeeds.get(sample.turnId) ?? { tokens:0, ms:0, samples:0, sources:new Set() };
|
|
123
|
+
turn.tokens += measurement?.output ?? sample.output;
|
|
115
124
|
turn.ms += elapsed;
|
|
116
125
|
turn.samples += 1;
|
|
126
|
+
turn.sources.add(source);
|
|
117
127
|
turnSpeeds.set(sample.turnId, turn);
|
|
118
128
|
}
|
|
119
129
|
// 按回合顺序取最近五个有效回合;同轮多步合并,无数据的回合不占名额。
|
|
120
130
|
const recentTurns = [...state.turns].filter(id => turnSpeeds.has(id)).slice(-5);
|
|
131
|
+
const speedSources = new Set();
|
|
121
132
|
for (const id of recentTurns) {
|
|
122
133
|
const turn = turnSpeeds.get(id);
|
|
123
134
|
decodeTokens += turn.tokens;
|
|
124
135
|
decodeMs += turn.ms;
|
|
125
136
|
speedSamples += turn.samples;
|
|
137
|
+
for (const source of turn.sources) speedSources.add(source);
|
|
126
138
|
}
|
|
127
139
|
const total = state.total;
|
|
128
140
|
const input = total?.input ?? 0;
|
|
@@ -144,6 +156,8 @@ export function sessionStatusSnapshot(state, threadId) {
|
|
|
144
156
|
cacheHitPercent: input > 0 ? Math.round(cached / input * 100) : null,
|
|
145
157
|
observedTps: decodeMs > 0 ? Math.round(decodeTokens / (decodeMs / 1000)) : null,
|
|
146
158
|
speedSamples,
|
|
159
|
+
speedSource: speedSources.size > 1 ? "mixed" : [...speedSources][0] ?? unavailableSource,
|
|
160
|
+
speedUnavailableReason: decodeMs > 0 ? null : unavailableReason,
|
|
147
161
|
observedModelMs: decodeMs,
|
|
148
162
|
toolMs: state.toolMs,
|
|
149
163
|
averageFirstTokenMs: state.ttftSamples > 0 ? Math.round(state.ttftTotalMs / state.ttftSamples) : null,
|
|
@@ -7,20 +7,34 @@ const copyByLocale = { "zh-CN": {
|
|
|
7
7
|
firstToken: "平均首字延迟", input: "输入", cached: "缓存输入", output: "输出",
|
|
8
8
|
sum: "合计", capacity: "窗口容量", contextShare: "上下文占用", compactions: "压缩次数",
|
|
9
9
|
instructions: "指令", messages: "消息", toolHistory: "工具交互", other: "未分类",
|
|
10
|
-
speedHelp: "关于 TPS", speedInfo: "
|
|
10
|
+
speedHelp: "关于 TPS", speedInfo: "根据会话日志估算生成速度,包含推理输出。",
|
|
11
|
+
speedInfoOcx: "根据 OCX 记录估算生成速度,包含推理输出。",
|
|
12
|
+
speedInfoMixed: "根据 OCX 记录和会话日志估算生成速度,包含推理输出。",
|
|
13
|
+
speedWindow: "取最近 5 个有有效数据的回合,不足 5 轮时按已有数据计算,仅供参考。",
|
|
14
|
+
speedUnavailable: "有效计时不足,暂无法估算。",
|
|
11
15
|
}, en: {
|
|
12
16
|
turns: "turns", steps: "steps", speed: "TPS", cache: "Cache", cacheDetail: "Cache hit",
|
|
13
17
|
statistics: "Session stats", usage: "Token usage", context: "Context", total: "Total",
|
|
14
18
|
firstToken: "Avg first token", input: "Input", cached: "Cached input", output: "Output",
|
|
15
19
|
sum: "Sum", capacity: "Window size", contextShare: "Context used", compactions: "Compactions",
|
|
16
20
|
instructions: "Instructions", messages: "Messages", toolHistory: "Tool activity", other: "Unclassified",
|
|
17
|
-
speedHelp: "About TPS", speedInfo: "
|
|
21
|
+
speedHelp: "About TPS", speedInfo: "Generation speed estimated from session logs, including reasoning output.",
|
|
22
|
+
speedInfoOcx: "Generation speed estimated from OCX records, including reasoning output.",
|
|
23
|
+
speedInfoMixed: "Generation speed estimated from OCX records and session logs, including reasoning output.",
|
|
24
|
+
speedWindow: "Uses the latest 5 turns with valid data, or fewer when unavailable. For reference only.",
|
|
25
|
+
speedUnavailable: "Not enough valid timing data to estimate speed.",
|
|
18
26
|
} };
|
|
19
27
|
function selectedCopy() {
|
|
20
28
|
const language = document.documentElement.lang || navigator.languages?.[0] || navigator.language || "en";
|
|
21
29
|
return String(language).toLowerCase().startsWith("zh") ? copyByLocale["zh-CN"] : copyByLocale.en;
|
|
22
30
|
}
|
|
23
31
|
|
|
32
|
+
function speedTooltip(value, copy) {
|
|
33
|
+
const source = value.speedSource === "ocx" ? copy.speedInfoOcx
|
|
34
|
+
: value.speedSource === "mixed" ? copy.speedInfoMixed : copy.speedInfo;
|
|
35
|
+
return `${source} ${value.observedTps == null ? copy.speedUnavailable : copy.speedWindow}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
// 图标来自 Lucide Static 与 Phosphor Icons;许可见 vendor/。
|
|
25
39
|
const icons = {
|
|
26
40
|
// Lucide info,使用官方 SVG 路径。
|
|
@@ -365,7 +379,7 @@ function showPanel(kind) {
|
|
|
365
379
|
const speedLabel = row(dl, copy.speed, snapshot.observedTps == null ? "—" : `${number(snapshot.observedTps)} tok/s`);
|
|
366
380
|
speedLabel.className = "tps-label";
|
|
367
381
|
tpsInfo.setAttribute("aria-label", copy.speedHelp);
|
|
368
|
-
tpsInfo.setAttribute("data-minecodex-tooltip", copy
|
|
382
|
+
tpsInfo.setAttribute("data-minecodex-tooltip", speedTooltip(snapshot, copy));
|
|
369
383
|
speedLabel.append(tpsInfo);
|
|
370
384
|
} else if (kind === "usage") {
|
|
371
385
|
row(dl, copy.total, tokenValue(snapshot.totalTokens));
|