mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat: add cache-aware token usage dashboard
Track normalized Claude, Codex, and ACP usage with incremental SQLite backfill. Exclude imported transcript history, rebuild usage after history rewrites, and expose an owner-only dashboard with cache-aware totals and breakdowns. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -645,7 +645,14 @@ describe('codexLocalLauncher', () => {
|
||||
[
|
||||
JSON.stringify({ type: 'session_meta', payload: { id: 'codex-thread-import' } }),
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'old imported prompt' } }),
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } })
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'old imported message' } }),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { total_token_usage: { input_tokens: 100, output_tokens: 10 } }
|
||||
}
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
@@ -694,7 +701,14 @@ describe('codexLocalLauncher', () => {
|
||||
transcriptPath,
|
||||
[
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'new local prompt' } }),
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'new local response' } })
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'new local response' } }),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { total_token_usage: { input_tokens: 120, output_tokens: 12 } }
|
||||
}
|
||||
})
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
await wait(700);
|
||||
@@ -713,6 +727,17 @@ describe('codexLocalLauncher', () => {
|
||||
message: 'new local response',
|
||||
id: expect.any(String)
|
||||
});
|
||||
const tokenMessages = agentMessages.filter((message) => (
|
||||
message as { type?: string }
|
||||
).type === 'token_count') as Array<Record<string, unknown>>;
|
||||
expect(tokenMessages).toHaveLength(2);
|
||||
expect(tokenMessages[0]).toMatchObject({ hapiUsageScope: 'imported-history' });
|
||||
expect(tokenMessages[0]).not.toHaveProperty('thread_id');
|
||||
expect(tokenMessages[1]).toMatchObject({
|
||||
threadId: 'codex-thread-import',
|
||||
thread_id: 'codex-thread-import',
|
||||
hapiUsageScope: 'managed'
|
||||
});
|
||||
});
|
||||
|
||||
it('replays semantic chat and tool events once and keeps a same-turn preface before its plan', async () => {
|
||||
|
||||
@@ -202,7 +202,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
}
|
||||
session.onSessionFound(sessionId);
|
||||
},
|
||||
onEvent: (event) => {
|
||||
onEvent: (event, context) => {
|
||||
const observedReasoningEffort = extractTurnContextReasoningEffort(event);
|
||||
if (observedReasoningEffort !== undefined) {
|
||||
session.setModelReasoningEffort(observedReasoningEffort);
|
||||
@@ -242,7 +242,19 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
flushPendingExecWrapper(message.callId, message);
|
||||
}
|
||||
} else {
|
||||
session.sendAgentMessage(message);
|
||||
const scopedMessage = message.type !== 'token_count'
|
||||
? message
|
||||
: context.replayedHistory
|
||||
? { ...message, hapiUsageScope: 'imported-history' }
|
||||
: primarySessionId
|
||||
? {
|
||||
...message,
|
||||
threadId: primarySessionId,
|
||||
thread_id: primarySessionId,
|
||||
hapiUsageScope: 'managed'
|
||||
}
|
||||
: message;
|
||||
session.sendAgentMessage(scopedMessage);
|
||||
}
|
||||
}
|
||||
if (converted?.finishedTurnId) {
|
||||
|
||||
@@ -104,16 +104,28 @@ describe('codexSessionScanner', () => {
|
||||
].join('\n') + '\n'
|
||||
);
|
||||
|
||||
const replayFlags: boolean[] = [];
|
||||
scanner = await createCodexSessionScanner({
|
||||
transcriptPath,
|
||||
replayExistingHistory: true,
|
||||
onEvent: (event) => events.push(event)
|
||||
onEvent: (event, context) => {
|
||||
events.push(event);
|
||||
replayFlags.push(context.replayedHistory);
|
||||
}
|
||||
});
|
||||
|
||||
await wait(300);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0]?.type).toBe('session_meta');
|
||||
expect(events[1]?.payload).toEqual({ type: 'agent_message', message: 'old' });
|
||||
expect(replayFlags).toEqual([true, true]);
|
||||
|
||||
await appendFile(
|
||||
transcriptPath,
|
||||
JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'new' } }) + '\n'
|
||||
);
|
||||
await scanner.flush();
|
||||
expect(replayFlags).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it('reports session id from the transcript metadata', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CodexSessionEvent } from './codexEventConverter';
|
||||
|
||||
interface CodexSessionScannerOptions {
|
||||
transcriptPath: string | null;
|
||||
onEvent: (event: CodexSessionEvent) => void;
|
||||
onEvent: (event: CodexSessionEvent, context: { replayedHistory: boolean }) => void;
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
replayExistingHistory?: boolean;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export async function createCodexSessionScanner(opts: CodexSessionScannerOptions
|
||||
|
||||
class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
private transcriptPath: string | null;
|
||||
private readonly onEvent: (event: CodexSessionEvent) => void;
|
||||
private readonly onEvent: (event: CodexSessionEvent, context: { replayedHistory: boolean }) => void;
|
||||
private readonly onSessionId?: (sessionId: string) => void;
|
||||
private readonly fileEpochByPath = new Map<string, number>();
|
||||
private readonly fileStateByPath = new Map<string, {
|
||||
@@ -45,6 +45,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
nextLineIndex: number;
|
||||
}>();
|
||||
private replayExistingHistoryOnNextAttach: boolean;
|
||||
private replayingExistingHistory = false;
|
||||
private observedSessionId: string | null = null;
|
||||
|
||||
constructor(opts: CodexSessionScannerOptions) {
|
||||
@@ -92,8 +93,13 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
}
|
||||
|
||||
protected async handleFileScan(stats: SessionFileScanStats<CodexSessionEvent>): Promise<void> {
|
||||
for (const event of stats.events) {
|
||||
this.onEvent(event);
|
||||
const replayedHistory = this.replayingExistingHistory;
|
||||
try {
|
||||
for (const event of stats.events) {
|
||||
this.onEvent(event, { replayedHistory });
|
||||
}
|
||||
} finally {
|
||||
this.replayingExistingHistory = false;
|
||||
}
|
||||
if (stats.newCount > 0) {
|
||||
logger.debug(`[codex-session-scanner] ${stats.newCount} new events from ${stats.filePath}`);
|
||||
@@ -106,9 +112,11 @@ class CodexSessionScannerImpl extends BaseSessionScanner<CodexSessionEvent> {
|
||||
// 中文注释:导入既有 Codex thread 时,首次挂接 transcript 不能先 prime 到 EOF,
|
||||
// 否则 Hapi 只会看到后续增量,客户端里已经存在的最新消息会被跳过。
|
||||
this.replayExistingHistoryOnNextAttach = false;
|
||||
this.replayingExistingHistory = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.replayingExistingHistory = false;
|
||||
await this.primeTranscript(filePath);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user