mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
fix: preserve usage model and local dates
via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -89,10 +89,11 @@ 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,
|
||||
@@ -106,6 +107,19 @@ describe('convertAgentMessage', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -36,7 +37,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,6 +49,7 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
@@ -203,6 +214,10 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
session.onSessionFound(sessionId);
|
||||
},
|
||||
onEvent: (event, context) => {
|
||||
const observedModel = extractTurnContextModel(event);
|
||||
if (observedModel !== undefined) {
|
||||
transcriptModel = observedModel;
|
||||
}
|
||||
const observedReasoningEffort = extractTurnContextReasoningEffort(event);
|
||||
if (observedReasoningEffort !== undefined) {
|
||||
session.setModelReasoningEffort(observedReasoningEffort);
|
||||
@@ -245,15 +260,16 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch
|
||||
const scopedMessage = message.type !== 'token_count'
|
||||
? message
|
||||
: context.replayedHistory
|
||||
? { ...message, hapiUsageScope: 'imported-history' }
|
||||
? { ...message, model: transcriptModel, hapiUsageScope: 'imported-history' }
|
||||
: primarySessionId
|
||||
? {
|
||||
...message,
|
||||
model: transcriptModel,
|
||||
threadId: primarySessionId,
|
||||
thread_id: primarySessionId,
|
||||
hapiUsageScope: 'managed'
|
||||
}
|
||||
: message;
|
||||
: { ...message, model: transcriptModel };
|
||||
session.sendAgentMessage(scopedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +77,10 @@ describe('convertKimiWireEvent', () => {
|
||||
uuid: 's1',
|
||||
usage: { inputOther: 100, output: 20, inputCacheRead: 50, inputCacheCreation: 10 }
|
||||
}
|
||||
})).toEqual({
|
||||
}, 'kimi-k2.5')).toEqual({
|
||||
message: {
|
||||
type: 'token_count',
|
||||
model: 'kimi-k2.5',
|
||||
info: { total: { inputTokens: 160, outputTokens: 20, cachedInputTokens: 50 } }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,6 +149,7 @@ 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,18 @@ import { describe, expect, it } from 'bun:test'
|
||||
import { Store } from '../store'
|
||||
import { getUsageSummary } from './usageService'
|
||||
|
||||
function addAgentMessage(store: Store, sessionId: string, content: unknown): void {
|
||||
store.messages.addMessage(sessionId, { role: 'agent', content })
|
||||
function addAgentMessage(store: Store, sessionId: string, content: unknown, createdAt?: number): void {
|
||||
if (createdAt === undefined) {
|
||||
store.messages.addMessage(sessionId, { role: 'agent', content })
|
||||
return
|
||||
}
|
||||
store.messages.copyMessageToSession(sessionId, {
|
||||
content: { role: 'agent', content },
|
||||
createdAt,
|
||||
localId: null,
|
||||
invokedAt: createdAt,
|
||||
scheduledAt: null
|
||||
})
|
||||
}
|
||||
|
||||
describe('usage service', () => {
|
||||
@@ -172,6 +182,85 @@ describe('usage service', () => {
|
||||
expect(result.totals.requests).toBe(1)
|
||||
expect(result.totals.totalTokens).toBe(110)
|
||||
expect(result.totals.uncachedTokens).toBe(30)
|
||||
expect(result.byModel).toEqual([expect.objectContaining({ key: 'unknown' })])
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('preserves event-level models across model switches and epoch rebuilds', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession(
|
||||
'codex-model-switch-test',
|
||||
{ path: '/tmp', host: 'test', flavor: 'codex' },
|
||||
null,
|
||||
'default',
|
||||
'initial-model'
|
||||
)
|
||||
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'token_count',
|
||||
model: 'old-model',
|
||||
thread_id: 'thread-1',
|
||||
turn_id: 'turn-1',
|
||||
info: {
|
||||
total_token_usage: { input_tokens: 100, output_tokens: 10 },
|
||||
last_token_usage: { input_tokens: 100, output_tokens: 10 }
|
||||
}
|
||||
}
|
||||
})
|
||||
addAgentMessage(store, session.id, {
|
||||
type: 'codex',
|
||||
data: {
|
||||
type: 'token_count',
|
||||
model: 'new-model',
|
||||
thread_id: 'thread-1',
|
||||
turn_id: 'turn-2',
|
||||
info: {
|
||||
total_token_usage: { input_tokens: 140, output_tokens: 15 },
|
||||
last_token_usage: { input_tokens: 40, output_tokens: 5 }
|
||||
}
|
||||
}
|
||||
})
|
||||
store.sessions.setSessionModel(session.id, 'latest-session-model', 'default')
|
||||
|
||||
const expectedModels = [
|
||||
expect.objectContaining({ key: 'old-model', totalTokens: 110 }),
|
||||
expect.objectContaining({ key: 'new-model', totalTokens: 45 })
|
||||
]
|
||||
expect(getUsageSummary(store, 'default', 'all').byModel).toEqual(expectedModels)
|
||||
|
||||
store.messages.bumpMessageEpoch(session.id)
|
||||
expect(getUsageSummary(store, 'default', 'all').byModel).toEqual(expectedModels)
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('buckets daily usage using positive and negative viewer offsets', () => {
|
||||
const store = new Store(':memory:')
|
||||
const session = store.sessions.getOrCreateSession(
|
||||
'timezone-usage-test',
|
||||
{ path: '/tmp', host: 'test', flavor: 'claude' },
|
||||
null,
|
||||
'default'
|
||||
)
|
||||
const usage = (id: string) => ({
|
||||
type: 'output',
|
||||
data: {
|
||||
type: 'assistant',
|
||||
message: { id, usage: { input_tokens: 10, output_tokens: 2 } }
|
||||
}
|
||||
})
|
||||
addAgentMessage(store, session.id, usage('after-utc-midnight'), Date.parse('2026-01-15T00:30:00Z'))
|
||||
addAgentMessage(store, session.id, usage('before-utc-midnight'), Date.parse('2026-01-15T23:30:00Z'))
|
||||
|
||||
expect(getUsageSummary(store, 'default', 'all', 60).daily.map((row) => row.key)).toEqual([
|
||||
'2026-01-14',
|
||||
'2026-01-15'
|
||||
])
|
||||
expect(getUsageSummary(store, 'default', 'all', -60).daily.map((row) => row.key)).toEqual([
|
||||
'2026-01-15',
|
||||
'2026-01-16'
|
||||
])
|
||||
store.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -31,10 +31,6 @@ function sessionAgent(session: StoredSession): string {
|
||||
return typeof flavor === 'string' && flavor.trim() ? flavor.trim() : 'unknown'
|
||||
}
|
||||
|
||||
function sessionModel(session: StoredSession): string | null {
|
||||
return typeof session.model === 'string' && session.model.trim() ? session.model.trim() : null
|
||||
}
|
||||
|
||||
function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageEvent | null {
|
||||
const envelope = asRecord(message.content)
|
||||
if (envelope?.role !== 'agent') return null
|
||||
@@ -58,7 +54,7 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE
|
||||
const providerId = typeof assistant?.id === 'string' ? assistant.id : message.id
|
||||
const model = typeof assistant?.model === 'string' && assistant.model.trim()
|
||||
? assistant.model.trim()
|
||||
: sessionModel(session)
|
||||
: null
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sourceKey: `claude|${providerId}`,
|
||||
@@ -124,6 +120,9 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE
|
||||
: typeof data.turn_id === 'string'
|
||||
? data.turn_id
|
||||
: ''
|
||||
const model = typeof data.model === 'string' && data.model.trim()
|
||||
? data.model.trim()
|
||||
: null
|
||||
return {
|
||||
sessionId: session.id,
|
||||
sourceKey: isCumulative
|
||||
@@ -141,7 +140,7 @@ function parseUsageEvent(session: StoredSession, message: StoredMessage): UsageE
|
||||
sourceSeq: message.seq,
|
||||
createdAt: message.createdAt,
|
||||
agent,
|
||||
model: sessionModel(session),
|
||||
model,
|
||||
kind: isCumulative ? 'cumulative' : 'delta',
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
@@ -225,11 +224,16 @@ function toBucket(key: string, totals: Totals): UsageSummaryBucket {
|
||||
return { key, ...totals }
|
||||
}
|
||||
|
||||
function dayKey(timestamp: number): string {
|
||||
return new Date(timestamp).toISOString().slice(0, 10)
|
||||
function dayKey(timestamp: number, timezoneOffset: number): string {
|
||||
return new Date(timestamp - timezoneOffset * 60_000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function getUsageSummary(store: Store, namespace: string, range: string | undefined): UsageSummaryResponse {
|
||||
export function getUsageSummary(
|
||||
store: Store,
|
||||
namespace: string,
|
||||
range: string | undefined,
|
||||
timezoneOffset: number = 0
|
||||
): UsageSummaryResponse {
|
||||
const sessions = store.sessions.getSessionsByNamespace(namespace)
|
||||
// This is intentionally lazy. Existing HAPI databases have no usage table;
|
||||
// the first dashboard request backfills history, while later requests only
|
||||
@@ -289,9 +293,10 @@ export function getUsageSummary(store: Store, namespace: string, range: string |
|
||||
? inputTokens + cacheReadTokens + cacheCreationTokens
|
||||
: inputTokens
|
||||
addTotals(totals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
const dailyTotals = daily.get(dayKey(event.createdAt)) ?? emptyTotals()
|
||||
const eventDayKey = dayKey(event.createdAt, timezoneOffset)
|
||||
const dailyTotals = daily.get(eventDayKey) ?? emptyTotals()
|
||||
addTotals(dailyTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
daily.set(dayKey(event.createdAt), dailyTotals)
|
||||
daily.set(eventDayKey, dailyTotals)
|
||||
const agentTotals = byAgent.get(event.agent) ?? emptyTotals()
|
||||
addTotals(agentTotals, normalizedInputTokens, outputTokens, cacheReadTokens, cacheCreationTokens)
|
||||
byAgent.set(event.agent, agentTotals)
|
||||
|
||||
@@ -42,4 +42,20 @@ describe('GET /api/usage/summary', () => {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('validates positive and negative timezone offset bounds', async () => {
|
||||
const store = new Store(':memory:')
|
||||
try {
|
||||
for (const offset of [-840, 840]) {
|
||||
const response = await createApp(store, 'default').request(`/api/usage/summary?timezoneOffset=${offset}`)
|
||||
expect(response.status).toBe(200)
|
||||
}
|
||||
for (const offset of [-841, 841, 1.5]) {
|
||||
const response = await createApp(store, 'default').request(`/api/usage/summary?timezoneOffset=${offset}`)
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
} finally {
|
||||
store.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,12 @@ export function createUsageRoutes(store: Store): Hono<WebAppEnv> {
|
||||
return c.json({ error: 'Usage summary is only available to the hub owner' }, 403)
|
||||
}
|
||||
const range = c.req.query('range')
|
||||
const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range)
|
||||
const timezoneOffsetParam = c.req.query('timezoneOffset')
|
||||
const timezoneOffset = timezoneOffsetParam === undefined ? 0 : Number(timezoneOffsetParam)
|
||||
if (!Number.isInteger(timezoneOffset) || timezoneOffset < -840 || timezoneOffset > 840) {
|
||||
return c.json({ error: 'timezoneOffset must be an integer between -840 and 840' }, 400)
|
||||
}
|
||||
const response: UsageSummaryResponse = getUsageSummary(store, c.get('namespace'), range, timezoneOffset)
|
||||
c.header('Cache-Control', 'no-store')
|
||||
return c.json(response)
|
||||
})
|
||||
|
||||
@@ -648,8 +648,15 @@ export class ApiClient {
|
||||
return await this.request<SqliteStorageUsageResponse>('/api/storage/sqlite')
|
||||
}
|
||||
|
||||
async getUsageSummary(range: '7d' | '30d' | 'all' = '7d'): Promise<UsageSummaryResponse> {
|
||||
return await this.request<UsageSummaryResponse>(`/api/usage/summary?range=${encodeURIComponent(range)}`)
|
||||
async getUsageSummary(
|
||||
range: '7d' | '30d' | 'all' = '7d',
|
||||
timezoneOffset: number = new Date().getTimezoneOffset()
|
||||
): Promise<UsageSummaryResponse> {
|
||||
const params = new URLSearchParams({
|
||||
range,
|
||||
timezoneOffset: String(timezoneOffset)
|
||||
})
|
||||
return await this.request<UsageSummaryResponse>(`/api/usage/summary?${params.toString()}`)
|
||||
}
|
||||
|
||||
async listMachineDirectory(
|
||||
|
||||
@@ -4,7 +4,7 @@ export const queryKeys = {
|
||||
messages: (sessionId: string) => ['messages', sessionId] as const,
|
||||
machines: ['machines'] as const,
|
||||
sqliteStorage: ['sqlite-storage'] as const,
|
||||
usageSummary: (range: string) => ['usage-summary', range] as const,
|
||||
usageSummary: (range: string, timezoneOffset: number) => ['usage-summary', range, timezoneOffset] as const,
|
||||
machineCodexModels: (machineId: string) => ['machine-codex-models', machineId] as const,
|
||||
gitStatus: (sessionId: string) => ['git-status', sessionId] as const,
|
||||
sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const,
|
||||
|
||||
@@ -47,11 +47,12 @@ export default function SettingsUsagePage() {
|
||||
const { api } = useAppContext()
|
||||
const { t } = useTranslation()
|
||||
const [range, setRange] = useState<UsageRange>('7d')
|
||||
const [timezoneOffset] = useState(() => new Date().getTimezoneOffset())
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.usageSummary(range),
|
||||
queryKey: queryKeys.usageSummary(range, timezoneOffset),
|
||||
queryFn: async () => {
|
||||
if (!api) throw new Error('API unavailable')
|
||||
return await api.getUsageSummary(range)
|
||||
return await api.getUsageSummary(range, timezoneOffset)
|
||||
},
|
||||
enabled: Boolean(api),
|
||||
staleTime: 30_000,
|
||||
|
||||
Reference in New Issue
Block a user