mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
feat: add cache-aware token usage dashboard (#1338)
* 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> * fix: preserve usage model and local dates via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix: normalize cached usage and timezone buckets via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -658,11 +658,12 @@ describe('AcpSdkBackend', () => {
|
||||
return {
|
||||
stopReason: 'end_turn',
|
||||
usage: {
|
||||
totalTokens: 13_892,
|
||||
totalTokens: 13_897,
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
thoughtTokens: 11,
|
||||
cachedReadTokens: 5_760
|
||||
cachedReadTokens: 5_760,
|
||||
cachedWriteTokens: 5
|
||||
}
|
||||
};
|
||||
},
|
||||
@@ -678,8 +679,9 @@ describe('AcpSdkBackend', () => {
|
||||
inputTokens: 8_119,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 5_760,
|
||||
cacheCreationTokens: 5,
|
||||
thoughtTokens: 11,
|
||||
totalTokens: 13_892,
|
||||
totalTokens: 13_897,
|
||||
contextTokens: 13_879,
|
||||
contextWindow: 65_536
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ type AcpPromptUsage = {
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
};
|
||||
|
||||
type AcpUsageUpdate = {
|
||||
@@ -548,6 +549,9 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
totalTokens: promptUsage.totalTokens,
|
||||
thoughtTokens: promptUsage.thoughtTokens,
|
||||
cacheReadTokens: promptUsage.cacheReadTokens,
|
||||
...(promptUsage.cacheCreationTokens !== undefined
|
||||
? { cacheCreationTokens: promptUsage.cacheCreationTokens }
|
||||
: {}),
|
||||
contextTokens: latestUsageUpdate ? latestUsageUpdate.contextTokens : undefined,
|
||||
contextWindow: latestUsageUpdate ? latestUsageUpdate.contextWindow : undefined
|
||||
});
|
||||
@@ -988,6 +992,12 @@ export class AcpSdkBackend implements AgentBackend {
|
||||
?? usage.cached_read_tokens
|
||||
?? usage.cachedInputTokens
|
||||
?? usage.cached_input_tokens
|
||||
) ?? undefined,
|
||||
cacheCreationTokens: this.asFiniteNumber(
|
||||
usage.cachedWriteTokens
|
||||
?? usage.cached_write_tokens
|
||||
?? usage.cacheCreationInputTokens
|
||||
?? usage.cache_creation_input_tokens
|
||||
) ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,13 +89,14 @@ describe('convertAgentMessage', () => {
|
||||
totalTokens: 13_892,
|
||||
contextTokens: 13_879,
|
||||
contextWindow: 65_536
|
||||
});
|
||||
}, 'kimi-k2.5');
|
||||
|
||||
expect(converted).toEqual({
|
||||
type: 'token_count',
|
||||
model: 'kimi-k2.5',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: 8119,
|
||||
inputTokens: 13879,
|
||||
outputTokens: 2,
|
||||
cachedInputTokens: 5760,
|
||||
thoughtTokens: 11,
|
||||
@@ -106,6 +107,41 @@ describe('convertAgentMessage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('includes cache creation in processed input', () => {
|
||||
const converted = convertAgentMessage({
|
||||
type: 'usage',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 10,
|
||||
cacheCreationTokens: 5
|
||||
}, 'pi-model');
|
||||
|
||||
expect(converted).toMatchObject({
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: 115,
|
||||
outputTokens: 20,
|
||||
cachedInputTokens: 10,
|
||||
cacheWriteInputTokens: 5
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps unknown usage models explicitly', () => {
|
||||
const converted = convertAgentMessage({
|
||||
type: 'usage',
|
||||
inputTokens: 10,
|
||||
outputTokens: 2
|
||||
});
|
||||
|
||||
expect(converted).toMatchObject({
|
||||
type: 'token_count',
|
||||
model: null
|
||||
});
|
||||
});
|
||||
it('returns null instead of echoing an unrecognized message shape', () => {
|
||||
// Unreachable through the type system, but callers forward any non-null
|
||||
// result straight into the chat stream — so the runtime contract has to
|
||||
|
||||
@@ -6,6 +6,7 @@ export type CodexMessage =
|
||||
| { type: 'reasoning'; message: string; id: string }
|
||||
| {
|
||||
type: 'token_count';
|
||||
model: string | null;
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: number;
|
||||
@@ -13,6 +14,7 @@ export type CodexMessage =
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
cacheWriteInputTokens?: number;
|
||||
};
|
||||
contextTokens?: number;
|
||||
modelContextWindow?: number;
|
||||
@@ -36,7 +38,7 @@ export type CodexMessage =
|
||||
| { type: 'plan'; entries: PlanItem[] }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export function convertAgentMessage(message: AgentMessage): CodexMessage | null {
|
||||
export function convertAgentMessage(message: AgentMessage, model?: string | null): CodexMessage | null {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return { type: 'message', message: message.text };
|
||||
@@ -48,13 +50,19 @@ export function convertAgentMessage(message: AgentMessage): CodexMessage | null
|
||||
case 'usage':
|
||||
return {
|
||||
type: 'token_count',
|
||||
model: typeof model === 'string' && model.trim() ? model.trim() : null,
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: message.inputTokens,
|
||||
inputTokens: message.inputTokens
|
||||
+ (message.cacheReadTokens ?? 0)
|
||||
+ (message.cacheCreationTokens ?? 0),
|
||||
outputTokens: message.outputTokens,
|
||||
totalTokens: message.totalTokens,
|
||||
thoughtTokens: message.thoughtTokens,
|
||||
cachedInputTokens: message.cacheReadTokens
|
||||
cachedInputTokens: message.cacheReadTokens,
|
||||
...(message.cacheCreationTokens !== undefined
|
||||
? { cacheWriteInputTokens: message.cacheCreationTokens }
|
||||
: {})
|
||||
},
|
||||
contextTokens: message.contextTokens,
|
||||
modelContextWindow: message.contextWindow
|
||||
|
||||
@@ -188,7 +188,8 @@ export async function runAgentSession(opts: {
|
||||
|
||||
try {
|
||||
await backend.prompt(agentSessionId, promptContent, (message) => {
|
||||
const converted = convertAgentMessage(message);
|
||||
const model = backend.getSessionModelsMetadata?.(agentSessionId)?.currentModelId;
|
||||
const converted = convertAgentMessage(message, model);
|
||||
if (converted) {
|
||||
session.sendAgentMessage(converted);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type AgentMessage =
|
||||
totalTokens?: number;
|
||||
thoughtTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheCreationTokens?: number;
|
||||
contextTokens?: number;
|
||||
contextWindow?: number;
|
||||
}
|
||||
|
||||
@@ -373,9 +373,9 @@ describe('codexLocalLauncher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks explicit and default reasoning effort from local turn context', async () => {
|
||||
it('tracks local turn context and stamps its model on usage', async () => {
|
||||
const transcriptPath = await writeTranscriptMeta('codex-turn-context.jsonl', 'codex-thread-effort');
|
||||
const { session, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default');
|
||||
const { session, agentMessages, getModelReasoningEffort, getModelReasoningEffortUpdates } = createSessionStub('default');
|
||||
let releaseRunBarrier: (() => void) | undefined;
|
||||
harness.runBarrier = new Promise((resolve) => {
|
||||
releaseRunBarrier = resolve;
|
||||
@@ -393,13 +393,13 @@ describe('codexLocalLauncher', () => {
|
||||
type: 'turn_context',
|
||||
payload: { effort: 'max' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'token_count', info: {} }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'turn_context',
|
||||
payload: { model: 'gpt-5.4' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'event_msg',
|
||||
payload: { type: 'token_count', info: {} }
|
||||
})
|
||||
].join('\n') + '\n');
|
||||
await wait(700);
|
||||
@@ -409,6 +409,10 @@ describe('codexLocalLauncher', () => {
|
||||
|
||||
expect(getModelReasoningEffortUpdates()).toEqual(['max', null]);
|
||||
expect(getModelReasoningEffort()).toBeNull();
|
||||
expect(agentMessages).toContainEqual(expect.objectContaining({
|
||||
type: 'token_count',
|
||||
model: 'gpt-5.4'
|
||||
}));
|
||||
});
|
||||
|
||||
it('renders nested Code Mode plans and commands without their covered exec wrapper', async () => {
|
||||
@@ -645,7 +649,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 +705,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 +731,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 () => {
|
||||
|
||||
@@ -36,6 +36,16 @@ function extractTurnContextReasoningEffort(event: CodexSessionEvent): ReasoningE
|
||||
return effort.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractTurnContextModel(event: CodexSessionEvent): string | null | undefined {
|
||||
if (event.type !== 'turn_context' || !event.payload || typeof event.payload !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const model = (event.payload as Record<string, unknown>).model;
|
||||
if (model === null) return null;
|
||||
if (typeof model !== 'string' || !model.trim()) return undefined;
|
||||
return model.trim();
|
||||
}
|
||||
|
||||
export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> {
|
||||
const resumeSessionId = session.sessionId;
|
||||
let primarySessionId = resumeSessionId;
|
||||
@@ -47,6 +57,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
let transcriptLocator: CodexTranscriptLocator | null = null;
|
||||
let scannerTranscriptPath: string | null = null;
|
||||
let scannerReplayedExistingHistory = false;
|
||||
let transcriptModel: string | null = null;
|
||||
const pendingPlansByTurnId = new Map<string, ProposedPlanMessage>();
|
||||
const pendingExecWrappers = new Map<string, PendingExecWrapper>();
|
||||
const toolHookBridge = new CodexToolHookBridge();
|
||||
@@ -202,7 +213,11 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
}
|
||||
session.onSessionFound(sessionId);
|
||||
},
|
||||
onEvent: (event) => {
|
||||
onEvent: (event, context) => {
|
||||
const observedModel = extractTurnContextModel(event);
|
||||
if (observedModel !== undefined) {
|
||||
transcriptModel = observedModel;
|
||||
}
|
||||
const observedReasoningEffort = extractTurnContextReasoningEffort(event);
|
||||
if (observedReasoningEffort !== undefined) {
|
||||
session.setModelReasoningEffort(observedReasoningEffort);
|
||||
@@ -242,7 +257,20 @@ 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, model: transcriptModel, hapiUsageScope: 'imported-history' }
|
||||
: primarySessionId
|
||||
? {
|
||||
...message,
|
||||
model: transcriptModel,
|
||||
threadId: primarySessionId,
|
||||
thread_id: primarySessionId,
|
||||
hapiUsageScope: 'managed'
|
||||
}
|
||||
: { ...message, model: transcriptModel };
|
||||
session.sendAgentMessage(scopedMessage);
|
||||
}
|
||||
}
|
||||
if (converted?.finishedTurnId) {
|
||||
|
||||
@@ -681,6 +681,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
let scheduleReadyAfterTurn: (() => void) | null = null;
|
||||
let clearReadyAfterTurnTimer: (() => void) | null = null;
|
||||
let turnInFlight = false;
|
||||
let usageModel: string | null = null;
|
||||
let allowAnonymousTerminalEvent = false;
|
||||
let invalidThreadId: string | null = null;
|
||||
let childAgentActivityInCurrentTurn = false;
|
||||
@@ -2874,6 +2875,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
const threadId = eventThreadId ?? this.currentThreadId;
|
||||
session.sendAgentMessage({
|
||||
...addCodexEventScope(msg, 'parent', threadId),
|
||||
model: asString(msg.model) ?? usageModel,
|
||||
id: randomUUID()
|
||||
});
|
||||
}
|
||||
@@ -3784,6 +3786,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
|
||||
...message.mode,
|
||||
model: session.getModel() ?? message.mode.model
|
||||
};
|
||||
usageModel = typeof mode.model === 'string' && mode.model.trim()
|
||||
? mode.model.trim()
|
||||
: null;
|
||||
const shouldSendCollaborationMode = supportsTurnCollaborationMode
|
||||
&& Boolean(mode.collaborationMode);
|
||||
const clientUserMessageId = message.items
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -458,7 +458,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
const converted = convertAgentMessage(message, this.currentBackendModel);
|
||||
if (converted) {
|
||||
this.session.sendAgentMessage(converted);
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
|
||||
} else if (event.type === 'assistant' || event.type === 'tool_call' || event.type === 'result') {
|
||||
const agentMsg = convertCursorEventToAgentMessage(event);
|
||||
if (agentMsg) {
|
||||
const codexMsg = convertAgentMessage(agentMsg);
|
||||
const codexMsg = convertAgentMessage(agentMsg, session.model);
|
||||
if (codexMsg) {
|
||||
session.sendAgentMessage(codexMsg);
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ class GrokRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message)
|
||||
const converted = convertAgentMessage(message, this.currentBackendModel)
|
||||
if (converted) this.session.sendAgentMessage(converted)
|
||||
|
||||
switch (message.type) {
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function kimiLocalLauncher(
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
const converted = convertKimiWireEvent(event);
|
||||
const converted = convertKimiWireEvent(event, session.getModel() ?? opts.model);
|
||||
if (!converted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
const converted = convertAgentMessage(message, this.currentBackendModel);
|
||||
if (converted) {
|
||||
this.session.sendAgentMessage(converted);
|
||||
}
|
||||
|
||||
@@ -77,10 +77,18 @@ describe('convertKimiWireEvent', () => {
|
||||
uuid: 's1',
|
||||
usage: { inputOther: 100, output: 20, inputCacheRead: 50, inputCacheCreation: 10 }
|
||||
}
|
||||
})).toEqual({
|
||||
}, 'kimi-k2.5')).toEqual({
|
||||
message: {
|
||||
type: 'token_count',
|
||||
info: { total: { inputTokens: 160, outputTokens: 20, cachedInputTokens: 50 } }
|
||||
model: 'kimi-k2.5',
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: 160,
|
||||
outputTokens: 20,
|
||||
cachedInputTokens: 50,
|
||||
cacheWriteInputTokens: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ function extractInputText(input: unknown): string | null {
|
||||
* Everything else (metadata, config.update, llm.request, usage.record,
|
||||
* step.begin, plan_mode.*, …) is ignored.
|
||||
*/
|
||||
export function convertKimiWireEvent(event: KimiWireEvent): KimiWireConversion | null {
|
||||
export function convertKimiWireEvent(event: KimiWireEvent, model?: string | null): KimiWireConversion | null {
|
||||
if (event.type === 'turn.prompt' || event.type === 'turn.steer') {
|
||||
const origin = asRecord(event.origin);
|
||||
if (asString(origin?.kind) !== 'user') {
|
||||
@@ -149,11 +149,13 @@ export function convertKimiWireEvent(event: KimiWireEvent): KimiWireConversion |
|
||||
return {
|
||||
message: {
|
||||
type: 'token_count',
|
||||
model: typeof model === 'string' && model.trim() ? model.trim() : null,
|
||||
info: {
|
||||
total: {
|
||||
inputTokens: inputOther + cacheRead + cacheCreation,
|
||||
outputTokens: asFiniteNumber(usage.output) ?? 0,
|
||||
cachedInputTokens: cacheRead
|
||||
cachedInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheCreation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +767,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
private handleAgentMessage(message: AgentMessage): void {
|
||||
const converted = convertAgentMessage(message);
|
||||
const converted = convertAgentMessage(message, this.currentBackendModel);
|
||||
if (converted) {
|
||||
this.session.sendAgentMessage(converted);
|
||||
}
|
||||
|
||||
@@ -543,6 +543,7 @@ describe('wireTransportEvents', () => {
|
||||
outputTokens: 200,
|
||||
totalTokens: 315,
|
||||
cacheReadTokens: 10,
|
||||
cacheCreationTokens: 5,
|
||||
contextTokens: 342,
|
||||
contextWindow: 200_000,
|
||||
});
|
||||
|
||||
+3
-3
@@ -326,7 +326,7 @@ async function publishPiTurnUsage(
|
||||
const usageMessage = convertPiTurnUsage(event, contextUsage);
|
||||
if (!usageMessage) return;
|
||||
|
||||
const converted = convertAgentMessage(usageMessage);
|
||||
const converted = convertAgentMessage(usageMessage, session.currentModel);
|
||||
if (converted) session.sendAgentMessage(converted);
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ export function wireTransportEvents(
|
||||
const accumulated = assistantMessageAccumulator.handleEvent(event);
|
||||
if (accumulated.length > 0) {
|
||||
for (const msg of accumulated) {
|
||||
const converted = convertAgentMessage(msg);
|
||||
const converted = convertAgentMessage(msg, session.currentModel);
|
||||
if (converted) session.sendAgentMessage(converted);
|
||||
}
|
||||
}
|
||||
@@ -371,7 +371,7 @@ export function wireTransportEvents(
|
||||
if (event.type !== 'message_start' && event.type !== 'message_update' && event.type !== 'message_end') {
|
||||
const messages = convertPiEvent(event);
|
||||
for (const msg of messages) {
|
||||
const converted = convertAgentMessage(msg);
|
||||
const converted = convertAgentMessage(msg, session.currentModel);
|
||||
if (converted) session.sendAgentMessage(converted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ describe('convertPiEvent', () => {
|
||||
outputTokens: 200,
|
||||
totalTokens: 315,
|
||||
cacheReadTokens: 10,
|
||||
cacheCreationTokens: 5,
|
||||
contextTokens: 342,
|
||||
contextWindow: 200_000
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export function convertPiTurnUsage(
|
||||
outputTokens: usage.output ?? 0,
|
||||
totalTokens: usage.totalTokens,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheCreationTokens: usage.cacheWrite,
|
||||
contextTokens: contextUsage?.tokens ?? usage.totalTokens,
|
||||
contextWindow: contextUsage?.contextWindow,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user