mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-30 00:05:33 +08:00
fix: refine token usage reporting
The old token UI mixed context pressure, cache counters, and dollar estimates. That made the percentage look precise even when provider data was incomplete or different. The composer and /cost view now show concrete counts instead of a pie percentage. Token payloads now share a smaller shape: used, inputTokens, outputTokens, and breakdown. Claude uses per-step usage where available and Codex reads total_token_usage events. Gemini reads its tokens object without inventing a context window. OpenCode reads opencode.db session totals and includes all token columns in used. The /cost backend no longer returns cache display fields or input/output dollar estimates. This avoids derived values that look reliable but are not comparable across providers. Verification: npm run typecheck; targeted eslint; OpenCode session provider test.
This commit is contained in:
@@ -285,43 +285,68 @@ function transformMessage(sdkMessage) {
|
|||||||
return sdkMessage;
|
return sdkMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts token usage from SDK result messages
|
* Extracts token usage from SDK messages.
|
||||||
* @param {Object} resultMessage - SDK result message
|
* Prefers per-step `message.usage` (Claude message payload), then falls back
|
||||||
|
* to result-level usage/modelUsage for compatibility across SDK versions.
|
||||||
|
* @param {Object} sdkMessage - SDK stream message
|
||||||
* @returns {Object|null} Token budget object or null
|
* @returns {Object|null} Token budget object or null
|
||||||
*/
|
*/
|
||||||
function extractTokenBudget(resultMessage) {
|
function extractTokenBudget(sdkMessage) {
|
||||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
if (!sdkMessage || typeof sdkMessage !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the first model's usage data
|
const messageUsage = sdkMessage.message?.usage || sdkMessage.usage;
|
||||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
if (messageUsage && typeof messageUsage === 'object') {
|
||||||
const modelData = resultMessage.modelUsage[modelKey];
|
const inputTokens = readNumber(messageUsage.input_tokens ?? messageUsage.inputTokens);
|
||||||
|
const outputTokens = readNumber(messageUsage.output_tokens ?? messageUsage.outputTokens);
|
||||||
|
const totalUsed = inputTokens + outputTokens;
|
||||||
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
|
|
||||||
if (!modelData) {
|
return {
|
||||||
|
used: totalUsed,
|
||||||
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sdkMessage.modelUsage || typeof sdkMessage.modelUsage !== 'object') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cumulative tokens if available (tracks total for the session)
|
// Fallback for older SDK messages with only modelUsage
|
||||||
// Otherwise fall back to per-request tokens
|
const modelKey = Object.keys(sdkMessage.modelUsage)[0];
|
||||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
const modelData = sdkMessage.modelUsage[modelKey];
|
||||||
const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
|
|
||||||
const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
|
|
||||||
const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
|
|
||||||
|
|
||||||
// Total used = input + output + cache tokens
|
if (!modelData || typeof modelData !== 'object') {
|
||||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Use configured context window budget from environment (default 160000)
|
const inputTokens = readNumber(modelData.cumulativeInputTokens ?? modelData.inputTokens);
|
||||||
// This is the user's budget limit, not the model's context window
|
const outputTokens = readNumber(modelData.cumulativeOutputTokens ?? modelData.outputTokens);
|
||||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
const totalUsed = inputTokens + outputTokens;
|
||||||
|
const contextWindow = parseInt(process.env.CONTEXT_WINDOW, 10) || 160000;
|
||||||
// Token calc logged via token-budget WS event
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
total: contextWindow
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,16 +709,10 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract and send token budget updates from result messages
|
// Extract and send token budget updates from assistant/result usage payloads
|
||||||
if (message.type === 'result') {
|
const tokenBudgetData = extractTokenBudget(message);
|
||||||
const models = Object.keys(message.modelUsage || {});
|
if (tokenBudgetData) {
|
||||||
if (models.length > 0) {
|
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
||||||
// Model info available in result message
|
|
||||||
}
|
|
||||||
const tokenBudgetData = extractTokenBudget(message);
|
|
||||||
if (tokenBudgetData) {
|
|
||||||
ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
// Gemini Response Handler - JSON Stream processing
|
// Gemini Response Handler - JSON Stream processing
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
|
import { createNormalizedMessage } from './shared/utils.js';
|
||||||
|
|
||||||
|
function buildGeminiTokenBudget(tokens) {
|
||||||
|
if (!tokens || typeof tokens !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = Number(tokens.input || 0);
|
||||||
|
const outputTokens = Number(tokens.output || 0);
|
||||||
|
const used = Number(tokens.total || inputTokens + outputTokens || 0);
|
||||||
|
if (used <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class GeminiResponseHandler {
|
class GeminiResponseHandler {
|
||||||
constructor(ws, options = {}) {
|
constructor(ws, options = {}) {
|
||||||
@@ -60,6 +84,17 @@ class GeminiResponseHandler {
|
|||||||
for (const msg of normalized) {
|
for (const msg of normalized) {
|
||||||
this.ws.send(msg);
|
this.ws.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenBudget = buildGeminiTokenBudget(event.tokens);
|
||||||
|
if (tokenBudget) {
|
||||||
|
this.ws.send(createNormalizedMessage({
|
||||||
|
kind: 'status',
|
||||||
|
text: 'token_budget',
|
||||||
|
tokenBudget,
|
||||||
|
sessionId: sid,
|
||||||
|
provider: 'gemini',
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
forceFlush() {
|
forceFlush() {
|
||||||
|
|||||||
159
server/index.js
159
server/index.js
@@ -10,8 +10,9 @@ import { spawn } from 'child_process';
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
import { AppError, WORKSPACES_ROOT, validateWorkspacePath } from '@/shared/utils.js';
|
import { AppError, WORKSPACES_ROOT, getOpenCodeDatabasePath, validateWorkspacePath } from '@/shared/utils.js';
|
||||||
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
|
import { closeSessionsWatcher, initializeSessionsWatcher } from '@/modules/providers/index.js';
|
||||||
import { createWebSocketServer } from '@/modules/websocket/index.js';
|
import { createWebSocketServer } from '@/modules/websocket/index.js';
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ import geminiRoutes from './routes/gemini.js';
|
|||||||
import pluginsRoutes from './routes/plugins.js';
|
import pluginsRoutes from './routes/plugins.js';
|
||||||
import providerRoutes from './modules/providers/provider.routes.js';
|
import providerRoutes from './modules/providers/provider.routes.js';
|
||||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||||
import { initializeDatabase, projectsDb } from './modules/database/index.js';
|
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||||
import { configureWebPush } from './services/vapid-keys.js';
|
import { configureWebPush } from './services/vapid-keys.js';
|
||||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||||
import { IS_PLATFORM } from './constants/config.js';
|
import { IS_PLATFORM } from './constants/config.js';
|
||||||
@@ -1141,33 +1142,127 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
return res.json({
|
return res.json({
|
||||||
used: 0,
|
used: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
breakdown: { input: 0, output: 0 },
|
||||||
unsupported: true,
|
unsupported: true,
|
||||||
message: 'Token usage tracking not available for Cursor sessions'
|
message: 'Token usage tracking not available for Cursor sessions'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Gemini sessions - they are raw logs in our current setup
|
|
||||||
if (provider === 'gemini') {
|
if (provider === 'gemini') {
|
||||||
|
const session = sessionsDb.getSessionById(safeSessionId);
|
||||||
|
const sessionFilePath = session?.jsonl_path;
|
||||||
|
if (!sessionFilePath) {
|
||||||
|
return res.json({
|
||||||
|
used: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
breakdown: { input: 0, output: 0 },
|
||||||
|
unsupported: true,
|
||||||
|
message: 'Token usage tracking not available for this Gemini session'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileContent;
|
||||||
|
try {
|
||||||
|
fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = fileContent.trim().split('\n');
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let totalTokens = 0;
|
||||||
|
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(lines[i]);
|
||||||
|
if (!entry.tokens || typeof entry.tokens !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputTokens = Number(entry.tokens.input || 0);
|
||||||
|
outputTokens = Number(entry.tokens.output || 0);
|
||||||
|
totalTokens = Number(entry.tokens.total || inputTokens + outputTokens || 0);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
used: 0,
|
used: totalTokens,
|
||||||
total: 0,
|
inputTokens,
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
outputTokens,
|
||||||
unsupported: true,
|
breakdown: {
|
||||||
message: 'Token usage tracking not available for Gemini sessions'
|
input: inputTokens,
|
||||||
|
output: outputTokens
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenCode token totals are surfaced through provider history reads.
|
|
||||||
// This legacy endpoint only knows file-backed session formats.
|
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
return res.json({
|
const dbPath = getOpenCodeDatabasePath();
|
||||||
used: 0,
|
if (!fs.existsSync(dbPath)) {
|
||||||
total: 0,
|
return res.status(404).json({ error: 'OpenCode database not found' });
|
||||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
}
|
||||||
unsupported: true,
|
|
||||||
message: 'Token usage tracking is available in OpenCode session history, not this legacy endpoint'
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
});
|
try {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(session)').all();
|
||||||
|
const columnNames = new Set(columns.map((column) => column.name));
|
||||||
|
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
|
||||||
|
if (!requiredColumns.every((column) => columnNames.has(column))) {
|
||||||
|
return res.json({
|
||||||
|
used: 0,
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
breakdown: { input: 0, output: 0 },
|
||||||
|
unsupported: true,
|
||||||
|
message: 'Token usage tracking is not available in this OpenCode database schema'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
tokens_input AS inputTokens,
|
||||||
|
tokens_output AS outputTokens,
|
||||||
|
tokens_reasoning AS reasoningTokens,
|
||||||
|
tokens_cache_read AS cacheReadTokens,
|
||||||
|
tokens_cache_write AS cacheWriteTokens
|
||||||
|
FROM session
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(safeSessionId);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: 'OpenCode session not found', sessionId: safeSessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
|
||||||
|
const outputTokens = Number(row.outputTokens || 0);
|
||||||
|
const totalUsed = Number(row.inputTokens || 0)
|
||||||
|
+ outputTokens
|
||||||
|
+ Number(row.reasoningTokens || 0)
|
||||||
|
+ Number(row.cacheReadTokens || 0)
|
||||||
|
+ Number(row.cacheWriteTokens || 0);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
used: totalUsed,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Codex sessions
|
// Handle Codex sessions
|
||||||
@@ -1210,6 +1305,8 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const lines = fileContent.trim().split('\n');
|
const lines = fileContent.trim().split('\n');
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
let totalTokens = 0;
|
let totalTokens = 0;
|
||||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||||
|
|
||||||
@@ -1222,7 +1319,9 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||||
const tokenInfo = entry.payload.info;
|
const tokenInfo = entry.payload.info;
|
||||||
if (tokenInfo.total_token_usage) {
|
if (tokenInfo.total_token_usage) {
|
||||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
inputTokens = tokenInfo.total_token_usage.input_tokens || 0;
|
||||||
|
outputTokens = tokenInfo.total_token_usage.output_tokens || 0;
|
||||||
|
totalTokens = tokenInfo.total_token_usage.total_tokens || inputTokens + outputTokens;
|
||||||
}
|
}
|
||||||
if (tokenInfo.model_context_window) {
|
if (tokenInfo.model_context_window) {
|
||||||
contextWindow = tokenInfo.model_context_window;
|
contextWindow = tokenInfo.model_context_window;
|
||||||
@@ -1237,7 +1336,13 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
used: totalTokens,
|
used: totalTokens,
|
||||||
total: contextWindow
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,8 +1385,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||||
let inputTokens = 0;
|
let inputTokens = 0;
|
||||||
let cacheCreationTokens = 0;
|
let outputTokens = 0;
|
||||||
let cacheReadTokens = 0;
|
|
||||||
|
|
||||||
// Find the latest assistant message with usage data (scan from end)
|
// Find the latest assistant message with usage data (scan from end)
|
||||||
for (let i = lines.length - 1; i >= 0; i--) {
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
@@ -1294,8 +1398,7 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
|
|
||||||
// Use token counts from latest assistant message only
|
// Use token counts from latest assistant message only
|
||||||
inputTokens = usage.input_tokens || 0;
|
inputTokens = usage.input_tokens || 0;
|
||||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
outputTokens = usage.output_tokens || 0;
|
||||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
|
||||||
|
|
||||||
break; // Stop after finding the latest assistant message
|
break; // Stop after finding the latest assistant message
|
||||||
}
|
}
|
||||||
@@ -1305,16 +1408,16 @@ app.get('/api/projects/:projectId/sessions/:sessionId/token-usage', authenticate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
const totalUsed = inputTokens + outputTokens;
|
||||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
used: totalUsed,
|
used: totalUsed,
|
||||||
total: contextWindow,
|
total: contextWindow,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
cacheCreation: cacheCreationTokens,
|
output: outputTokens
|
||||||
cacheRead: cacheReadTokens
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -88,22 +88,15 @@ function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
|||||||
const record = tokens as AnyRecord;
|
const record = tokens as AnyRecord;
|
||||||
const input = Number(record.input || 0);
|
const input = Number(record.input || 0);
|
||||||
const output = Number(record.output || 0);
|
const output = Number(record.output || 0);
|
||||||
const cached = Number(record.cached || 0);
|
const total = Number(record.total || input + output || 0);
|
||||||
const thoughts = Number(record.thoughts || 0);
|
|
||||||
const tool = Number(record.tool || 0);
|
|
||||||
|
|
||||||
const totalFromFields = input + output + cached + thoughts + tool;
|
|
||||||
const total = Number(record.total || totalFromFields || 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used: total,
|
used: total,
|
||||||
total: total,
|
inputTokens: input,
|
||||||
|
outputTokens: output,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
cached,
|
|
||||||
thoughts,
|
|
||||||
tool,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ type OpenCodeHistoryRow = {
|
|||||||
type OpenCodeTokenTotals = {
|
type OpenCodeTokenTotals = {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
cacheReadTokens: number;
|
|
||||||
cacheCreationTokens: number;
|
|
||||||
reasoningTokens: number;
|
reasoningTokens: number;
|
||||||
|
cacheReadTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openOpenCodeDatabase = (): Database.Database | null => {
|
const openOpenCodeDatabase = (): Database.Database | null => {
|
||||||
@@ -106,11 +106,13 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputTokens = totals.inputTokens;
|
const inputTokens = totals.inputTokens;
|
||||||
|
const displayInputTokens = inputTokens + totals.cacheReadTokens;
|
||||||
const outputTokens = totals.outputTokens;
|
const outputTokens = totals.outputTokens;
|
||||||
const cacheReadTokens = totals.cacheReadTokens;
|
const used = inputTokens
|
||||||
const cacheCreationTokens = totals.cacheCreationTokens;
|
+ outputTokens
|
||||||
const reasoningTokens = totals.reasoningTokens;
|
+ totals.reasoningTokens
|
||||||
const used = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens + reasoningTokens;
|
+ totals.cacheReadTokens
|
||||||
|
+ totals.cacheWriteTokens;
|
||||||
|
|
||||||
if (used <= 0) {
|
if (used <= 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -118,14 +120,50 @@ const buildTokenUsage = (totals: OpenCodeTokenTotals | undefined): AnyRecord | u
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
used,
|
used,
|
||||||
total: used,
|
inputTokens: displayInputTokens,
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
breakdown: {
|
||||||
cacheCreationTokens,
|
input: displayInputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readOpenCodeSessionColumnTokenUsage = (
|
||||||
|
db: Database.Database,
|
||||||
|
sessionId: string,
|
||||||
|
): AnyRecord | undefined => {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(session)').all() as { name: string }[];
|
||||||
|
const columnNames = new Set(columns.map((column) => column.name));
|
||||||
|
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
|
||||||
|
if (!requiredColumns.every((column) => columnNames.has(column))) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
tokens_input AS inputTokens,
|
||||||
|
tokens_output AS outputTokens,
|
||||||
|
tokens_reasoning AS reasoningTokens,
|
||||||
|
tokens_cache_read AS cacheReadTokens,
|
||||||
|
tokens_cache_write AS cacheWriteTokens
|
||||||
|
FROM session
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(sessionId) as OpenCodeTokenTotals | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTokenUsage({
|
||||||
|
inputTokens: Number(row.inputTokens ?? 0),
|
||||||
|
outputTokens: Number(row.outputTokens ?? 0),
|
||||||
|
reasoningTokens: Number(row.reasoningTokens ?? 0),
|
||||||
|
cacheReadTokens: Number(row.cacheReadTokens ?? 0),
|
||||||
|
cacheWriteTokens: Number(row.cacheWriteTokens ?? 0),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenCode stores per-message token counts on assistant `message.data` objects
|
* OpenCode stores per-message token counts on assistant `message.data` objects
|
||||||
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
* (see MessageV2.Assistant). Older DBs also had session-level counters; this
|
||||||
@@ -135,13 +173,18 @@ const aggregateOpenCodeSessionTokenUsage = (
|
|||||||
db: Database.Database,
|
db: Database.Database,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
): AnyRecord | undefined => {
|
): AnyRecord | undefined => {
|
||||||
|
const sessionColumnUsage = readOpenCodeSessionColumnTokenUsage(db, sessionId);
|
||||||
|
if (sessionColumnUsage) {
|
||||||
|
return sessionColumnUsage;
|
||||||
|
}
|
||||||
|
|
||||||
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
const rows = db.prepare('SELECT data FROM message WHERE session_id = ?').all(sessionId) as { data: string }[];
|
||||||
|
|
||||||
let inputTokens = 0;
|
let inputTokens = 0;
|
||||||
let outputTokens = 0;
|
let outputTokens = 0;
|
||||||
let cacheReadTokens = 0;
|
|
||||||
let cacheCreationTokens = 0;
|
|
||||||
let reasoningTokens = 0;
|
let reasoningTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let cacheWriteTokens = 0;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const info = readJsonRecord(row.data);
|
const info = readJsonRecord(row.data);
|
||||||
@@ -159,15 +202,15 @@ const aggregateOpenCodeSessionTokenUsage = (
|
|||||||
reasoningTokens += Number(tokens.reasoning ?? 0);
|
reasoningTokens += Number(tokens.reasoning ?? 0);
|
||||||
const cache = readObjectRecord(tokens.cache);
|
const cache = readObjectRecord(tokens.cache);
|
||||||
cacheReadTokens += Number(cache?.read ?? 0);
|
cacheReadTokens += Number(cache?.read ?? 0);
|
||||||
cacheCreationTokens += Number(cache?.write ?? 0);
|
cacheWriteTokens += Number(cache?.write ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildTokenUsage({
|
return buildTokenUsage({
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
cacheReadTokens,
|
|
||||||
cacheCreationTokens,
|
|
||||||
reasoningTokens,
|
reasoningTokens,
|
||||||
|
cacheReadTokens,
|
||||||
|
cacheWriteTokens,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
|||||||
path TEXT,
|
path TEXT,
|
||||||
agent TEXT,
|
agent TEXT,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
|
cost REAL NOT NULL DEFAULT 0,
|
||||||
|
tokens_input INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_output INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_reasoning INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_cache_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tokens_cache_write INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -124,9 +130,10 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
|||||||
);
|
);
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO session (
|
INSERT INTO session (
|
||||||
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived
|
id, project_id, slug, directory, title, version, time_created, time_updated, time_archived,
|
||||||
|
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
'open-session-1',
|
'open-session-1',
|
||||||
'project-1',
|
'project-1',
|
||||||
@@ -137,6 +144,11 @@ const createOpenCodeDatabase = async (homeDir: string, workspacePath: string): P
|
|||||||
1_700_000_000_000,
|
1_700_000_000_000,
|
||||||
1_700_000_004_000,
|
1_700_000_004_000,
|
||||||
null,
|
null,
|
||||||
|
10,
|
||||||
|
20,
|
||||||
|
7,
|
||||||
|
3,
|
||||||
|
2,
|
||||||
);
|
);
|
||||||
|
|
||||||
const userMessageData = JSON.stringify({
|
const userMessageData = JSON.stringify({
|
||||||
@@ -302,12 +314,13 @@ test('OpenCode sessions provider reads sqlite history and token usage', { concur
|
|||||||
assert.equal(history.messages[3]?.kind, 'tool_use');
|
assert.equal(history.messages[3]?.kind, 'tool_use');
|
||||||
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
assert.deepEqual(history.messages[3]?.toolResult, { content: 'ok', isError: false });
|
||||||
assert.deepEqual(history.tokenUsage, {
|
assert.deepEqual(history.tokenUsage, {
|
||||||
used: 35,
|
used: 42,
|
||||||
total: 35,
|
inputTokens: 13,
|
||||||
inputTokens: 10,
|
|
||||||
outputTokens: 20,
|
outputTokens: 20,
|
||||||
cacheReadTokens: 3,
|
breakdown: {
|
||||||
cacheCreationTokens: 2,
|
input: 13,
|
||||||
|
output: 20,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
const paged = await provider.fetchHistory('open-session-1', { limit: 2, offset: 0 });
|
||||||
|
|||||||
@@ -23,6 +23,34 @@ import { createNormalizedMessage } from './shared/utils.js';
|
|||||||
// Track active sessions
|
// Track active sessions
|
||||||
const activeCodexSessions = new Map();
|
const activeCodexSessions = new Map();
|
||||||
|
|
||||||
|
function readUsageNumber(value) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCodexTokenBudget(event) {
|
||||||
|
const info = event?.info || event?.payload?.info || event?.usage?.info;
|
||||||
|
const usage = info?.total_token_usage || event?.usage?.total_token_usage || event?.usage;
|
||||||
|
if (!usage || typeof usage !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = readUsageNumber(usage.input_tokens);
|
||||||
|
const outputTokens = readUsageNumber(usage.output_tokens);
|
||||||
|
const used = readUsageNumber(usage.total_tokens) || inputTokens + outputTokens;
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
total: readUsageNumber(info?.model_context_window || event?.usage?.model_context_window) || 200000,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform Codex SDK event to WebSocket message format
|
* Transform Codex SDK event to WebSocket message format
|
||||||
* @param {object} event - SDK event
|
* @param {object} event - SDK event
|
||||||
@@ -316,9 +344,11 @@ export async function queryCodex(command, options = {}, ws) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract and send token usage if available (normalized to match Claude format)
|
// Extract and send token usage if available (normalized to match Claude format)
|
||||||
if (event.type === 'turn.completed' && event.usage) {
|
if (event.type === 'turn.completed') {
|
||||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
const tokenBudget = extractCodexTokenBudget(event);
|
||||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
if (tokenBudget) {
|
||||||
|
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget, sessionId: capturedSessionId || sessionId || null, provider: 'codex' }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import fsSync from 'node:fs';
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
import { sessionsService } from './modules/providers/services/sessions.service.js';
|
||||||
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
|
||||||
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
import { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { createNormalizedMessage } from './shared/utils.js';
|
import { createNormalizedMessage, getOpenCodeDatabasePath } from './shared/utils.js';
|
||||||
|
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
||||||
|
|
||||||
@@ -20,6 +22,61 @@ function readOpenCodeSessionId(event) {
|
|||||||
return event.sessionID || event.sessionId || null;
|
return event.sessionID || event.sessionId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readOpenCodeTokenUsage(sessionId) {
|
||||||
|
const dbPath = getOpenCodeDatabasePath();
|
||||||
|
if (!sessionId || !fsSync.existsSync(dbPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
try {
|
||||||
|
const columns = db.prepare('PRAGMA table_info(session)').all();
|
||||||
|
const columnNames = new Set(columns.map((column) => column.name));
|
||||||
|
const requiredColumns = ['tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read', 'tokens_cache_write'];
|
||||||
|
if (!requiredColumns.every((column) => columnNames.has(column))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
tokens_input AS inputTokens,
|
||||||
|
tokens_output AS outputTokens,
|
||||||
|
tokens_reasoning AS reasoningTokens,
|
||||||
|
tokens_cache_read AS cacheReadTokens,
|
||||||
|
tokens_cache_write AS cacheWriteTokens
|
||||||
|
FROM session
|
||||||
|
WHERE id = ?
|
||||||
|
`).get(sessionId);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputTokens = Number(row.inputTokens || 0) + Number(row.cacheReadTokens || 0);
|
||||||
|
const outputTokens = Number(row.outputTokens || 0);
|
||||||
|
const used = Number(row.inputTokens || 0)
|
||||||
|
+ outputTokens
|
||||||
|
+ Number(row.reasoningTokens || 0)
|
||||||
|
+ Number(row.cacheReadTokens || 0)
|
||||||
|
+ Number(row.cacheWriteTokens || 0);
|
||||||
|
if (used <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
breakdown: {
|
||||||
|
input: inputTokens,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function spawnOpenCode(command, options = {}, ws) {
|
async function spawnOpenCode(command, options = {}, ws) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
const { sessionId, projectPath, cwd, model, sessionSummary } = options;
|
||||||
@@ -183,6 +240,17 @@ async function spawnOpenCode(command, options = {}, ws) {
|
|||||||
stdoutLineBuffer = '';
|
stdoutLineBuffer = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tokenBudget = readOpenCodeTokenUsage(finalSessionId);
|
||||||
|
if (tokenBudget) {
|
||||||
|
ws.send(createNormalizedMessage({
|
||||||
|
kind: 'status',
|
||||||
|
text: 'token_budget',
|
||||||
|
tokenBudget,
|
||||||
|
sessionId: finalSessionId,
|
||||||
|
provider: 'opencode',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
ws.send(createNormalizedMessage({
|
ws.send(createNormalizedMessage({
|
||||||
kind: 'complete',
|
kind: 'complete',
|
||||||
exitCode: code,
|
exitCode: code,
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ const builtInCommands = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "/cost",
|
name: "/cost",
|
||||||
description: "Display token usage and cost information",
|
description: "Display token usage information",
|
||||||
namespace: "builtin",
|
namespace: "builtin",
|
||||||
metadata: { type: "builtin" },
|
metadata: { type: "builtin" },
|
||||||
},
|
},
|
||||||
@@ -258,7 +258,7 @@ Custom commands can be created in:
|
|||||||
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
const catalog = (await providerModelsService.getProviderModels(provider)).models;
|
||||||
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
const model = await resolveCommandModel(provider, catalog, context?.sessionId);
|
||||||
|
|
||||||
const used =
|
const reportedUsed =
|
||||||
Number(
|
Number(
|
||||||
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
|
tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0,
|
||||||
) || 0;
|
) || 0;
|
||||||
@@ -266,16 +266,15 @@ Custom commands can be created in:
|
|||||||
Number(
|
Number(
|
||||||
tokenUsage.total ??
|
tokenUsage.total ??
|
||||||
tokenUsage.contextWindow ??
|
tokenUsage.contextWindow ??
|
||||||
parseInt(process.env.CONTEXT_WINDOW || "160000", 10),
|
0,
|
||||||
) || 160000;
|
) || 0;
|
||||||
const percentage =
|
|
||||||
total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
|
||||||
|
|
||||||
const inputTokensRaw =
|
const inputTokensRaw =
|
||||||
Number(
|
Number(
|
||||||
tokenUsage.inputTokens ??
|
tokenUsage.inputTokens ??
|
||||||
tokenUsage.input ??
|
tokenUsage.input ??
|
||||||
|
tokenUsage.input_tokens ??
|
||||||
tokenUsage.cumulativeInputTokens ??
|
tokenUsage.cumulativeInputTokens ??
|
||||||
|
tokenUsage.breakdown?.input ??
|
||||||
tokenUsage.promptTokens ??
|
tokenUsage.promptTokens ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
@@ -283,37 +282,21 @@ Custom commands can be created in:
|
|||||||
Number(
|
Number(
|
||||||
tokenUsage.outputTokens ??
|
tokenUsage.outputTokens ??
|
||||||
tokenUsage.output ??
|
tokenUsage.output ??
|
||||||
|
tokenUsage.output_tokens ??
|
||||||
tokenUsage.cumulativeOutputTokens ??
|
tokenUsage.cumulativeOutputTokens ??
|
||||||
|
tokenUsage.breakdown?.output ??
|
||||||
tokenUsage.completionTokens ??
|
tokenUsage.completionTokens ??
|
||||||
0,
|
0,
|
||||||
) || 0;
|
) || 0;
|
||||||
const cacheTokens =
|
const hasTokenBreakdown = inputTokensRaw > 0 || outputTokens > 0;
|
||||||
Number(
|
const used = reportedUsed || inputTokensRaw + outputTokens;
|
||||||
tokenUsage.cacheReadTokens ??
|
|
||||||
tokenUsage.cacheCreationTokens ??
|
|
||||||
tokenUsage.cacheTokens ??
|
|
||||||
tokenUsage.cachedTokens ??
|
|
||||||
0,
|
|
||||||
) || 0;
|
|
||||||
|
|
||||||
// If we only have total used tokens, treat them as input for display/estimation.
|
// If we only have total used tokens, keep the list populated without guessing output.
|
||||||
const inputTokens =
|
const inputTokens =
|
||||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0
|
hasTokenBreakdown
|
||||||
? inputTokensRaw + cacheTokens
|
? inputTokensRaw
|
||||||
: used;
|
: used;
|
||||||
|
|
||||||
// Rough default rates by provider (USD / 1M tokens).
|
|
||||||
const pricingByProvider = {
|
|
||||||
claude: { input: 3, output: 15 },
|
|
||||||
cursor: { input: 3, output: 15 },
|
|
||||||
codex: { input: 1.5, output: 6 },
|
|
||||||
};
|
|
||||||
const rates = pricingByProvider[provider] || pricingByProvider.claude;
|
|
||||||
|
|
||||||
const inputCost = (inputTokens / 1_000_000) * rates.input;
|
|
||||||
const outputCost = (outputTokens / 1_000_000) * rates.output;
|
|
||||||
const totalCost = inputCost + outputCost;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "builtin",
|
type: "builtin",
|
||||||
action: "cost",
|
action: "cost",
|
||||||
@@ -321,17 +304,10 @@ Custom commands can be created in:
|
|||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
used,
|
used,
|
||||||
total,
|
total,
|
||||||
percentage,
|
|
||||||
},
|
},
|
||||||
tokenBreakdown: {
|
tokenBreakdown: {
|
||||||
input: inputTokens,
|
input: inputTokens,
|
||||||
output: outputTokens,
|
output: outputTokens,
|
||||||
cache: cacheTokens,
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: inputCost.toFixed(4),
|
|
||||||
output: outputCost.toFixed(4),
|
|
||||||
total: totalCost.toFixed(4),
|
|
||||||
},
|
},
|
||||||
provider,
|
provider,
|
||||||
model,
|
model,
|
||||||
|
|||||||
@@ -97,17 +97,10 @@ export type CostCommandData = {
|
|||||||
tokenUsage?: {
|
tokenUsage?: {
|
||||||
used?: number;
|
used?: number;
|
||||||
total?: number;
|
total?: number;
|
||||||
percentage?: number;
|
|
||||||
};
|
|
||||||
cost?: {
|
|
||||||
input?: string;
|
|
||||||
output?: string;
|
|
||||||
total?: string;
|
|
||||||
};
|
};
|
||||||
tokenBreakdown?: {
|
tokenBreakdown?: {
|
||||||
input?: number;
|
input?: number;
|
||||||
output?: number;
|
output?: number;
|
||||||
cache?: number;
|
|
||||||
};
|
};
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|||||||
@@ -624,19 +624,23 @@ export function useChatSessionState({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||||
|
|
||||||
// Token usage fetch for Claude
|
// Initial token usage fetch for providers with file-backed usage data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProject || !selectedSession?.id) {
|
if (!selectedProject || !selectedSession?.id) {
|
||||||
setTokenBudget(null);
|
setTokenBudget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sessionProvider = selectedSession.__provider || 'claude';
|
const sessionProvider = selectedSession.__provider || 'claude';
|
||||||
if (sessionProvider !== 'claude') return;
|
if (sessionProvider !== 'claude' && sessionProvider !== 'codex' && sessionProvider !== 'gemini' && sessionProvider !== 'opencode') {
|
||||||
|
setTokenBudget(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchInitialTokenUsage = async () => {
|
const fetchInitialTokenUsage = async () => {
|
||||||
try {
|
try {
|
||||||
// Token usage endpoint is now keyed by the DB projectId.
|
// Token usage endpoint is now keyed by the DB projectId.
|
||||||
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage`;
|
const params = new URLSearchParams({ provider: sessionProvider });
|
||||||
|
const url = `/api/projects/${selectedProject.projectId}/sessions/${selectedSession.id}/token-usage?${params.toString()}`;
|
||||||
const response = await authenticatedFetch(url);
|
const response = await authenticatedFetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setTokenBudget(await response.json());
|
setTokenBudget(await response.json());
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import ClaudeStatus from './ClaudeStatus';
|
|||||||
import ImageAttachment from './ImageAttachment';
|
import ImageAttachment from './ImageAttachment';
|
||||||
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
import PermissionRequestsBanner from './PermissionRequestsBanner';
|
||||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||||
import TokenUsagePie from './TokenUsagePie';
|
import TokenUsageSummary from './TokenUsageSummary';
|
||||||
import {
|
import {
|
||||||
PromptInput,
|
PromptInput,
|
||||||
PromptInputHeader,
|
PromptInputHeader,
|
||||||
@@ -60,7 +60,7 @@ interface ChatComposerProps {
|
|||||||
onModeSwitch: () => void;
|
onModeSwitch: () => void;
|
||||||
thinkingMode: string;
|
thinkingMode: string;
|
||||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||||
tokenBudget: { used?: number; total?: number } | null;
|
tokenBudget: Record<string, unknown> | null;
|
||||||
slashCommandsCount: number;
|
slashCommandsCount: number;
|
||||||
onToggleCommandMenu: () => void;
|
onToggleCommandMenu: () => void;
|
||||||
hasInput: boolean;
|
hasInput: boolean;
|
||||||
@@ -361,7 +361,7 @@ export default function ChatComposer({
|
|||||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
<TokenUsageSummary usage={tokenBudget} />
|
||||||
|
|
||||||
<PromptInputButton
|
<PromptInputButton
|
||||||
tooltip={{ content: t('input.showAllCommands') }}
|
tooltip={{ content: t('input.showAllCommands') }}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
CircleHelp,
|
CircleHelp,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Coins,
|
Coins,
|
||||||
Command as CommandIcon,
|
|
||||||
Cpu,
|
Cpu,
|
||||||
Gauge,
|
Gauge,
|
||||||
Package,
|
Package,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
Timer,
|
Timer,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
X,
|
X,
|
||||||
Zap,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
import { Badge, Button, Dialog, DialogContent, DialogTitle, Input } from '../../../../shared/view/ui';
|
||||||
@@ -84,7 +82,7 @@ const PROVIDER_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const FALLBACK_COMMANDS: CommandEntry[] = [
|
const FALLBACK_COMMANDS: CommandEntry[] = [
|
||||||
{ name: '/models', description: 'Browse available models for the active provider.' },
|
{ name: '/models', description: 'Browse available models for the active provider.' },
|
||||||
{ name: '/cost', description: 'Review context usage and estimated token spend.' },
|
{ name: '/cost', description: 'Review token usage for the active session.' },
|
||||||
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
{ name: '/status', description: 'Inspect runtime, version, provider, and environment status.' },
|
||||||
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
{ name: '/memory', description: 'Open the project CLAUDE.md memory file.' },
|
||||||
{ name: '/config', description: 'Open settings and configuration.' },
|
{ name: '/config', description: 'Open settings and configuration.' },
|
||||||
@@ -99,13 +97,6 @@ const getProviderLabel = (provider: string | undefined, fallback = 'Unknown') =>
|
|||||||
return PROVIDER_LABELS[provider] || provider;
|
return PROVIDER_LABELS[provider] || provider;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clampPercentage = (value: number) => {
|
|
||||||
if (!Number.isFinite(value)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Math.max(0, Math.min(100, value));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
const formatNumber = (value: number) => {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return '0';
|
return '0';
|
||||||
@@ -113,11 +104,6 @@ const formatNumber = (value: number) => {
|
|||||||
return value.toLocaleString();
|
return value.toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatCurrency = (value: number | string | undefined) => {
|
|
||||||
const numeric = Number(value ?? 0);
|
|
||||||
return `$${Number.isFinite(numeric) ? numeric.toFixed(4) : '0.0000'}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function MetricCard({
|
function MetricCard({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -507,62 +493,52 @@ function ModelsContent({
|
|||||||
function CostContent({ data }: { data: CostCommandData }) {
|
function CostContent({ data }: { data: CostCommandData }) {
|
||||||
const used = Number(data.tokenUsage?.used ?? 0);
|
const used = Number(data.tokenUsage?.used ?? 0);
|
||||||
const total = Number(data.tokenUsage?.total ?? 0);
|
const total = Number(data.tokenUsage?.total ?? 0);
|
||||||
const percentage = clampPercentage(Number(data.tokenUsage?.percentage ?? 0));
|
|
||||||
const model = data.model || 'Unknown';
|
const model = data.model || 'Unknown';
|
||||||
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
const provider = getProviderLabel(data.provider, data.provider || 'Unknown');
|
||||||
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
const inputTokens = Number(data.tokenBreakdown?.input ?? 0);
|
||||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
||||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
const usageRows = [
|
||||||
const totalCost = Number(data.cost?.total ?? 0);
|
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
|
||||||
|
{ label: 'Input tokens', value: formatNumber(inputTokens), icon: TerminalSquare },
|
||||||
|
{ label: 'Output tokens', value: formatNumber(outputTokens), icon: Coins },
|
||||||
|
...(total > 0
|
||||||
|
? [{ label: 'Context window', value: formatNumber(total), icon: Gauge }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4 lg:grid-cols-[18rem_1fr]">
|
<div className="space-y-4">
|
||||||
<div className="rounded-3xl border border-primary/25 bg-primary/10 p-5 text-center">
|
<div className="overflow-hidden rounded-2xl border border-border/70 bg-background/75">
|
||||||
<div
|
{usageRows.map((row) => {
|
||||||
className="mx-auto grid h-40 w-40 place-items-center rounded-full p-2 shadow-inner"
|
const Icon = row.icon;
|
||||||
style={{
|
|
||||||
background: `conic-gradient(hsl(var(--primary)) ${percentage * 3.6}deg, hsl(var(--muted)) 0deg)`,
|
return (
|
||||||
}}
|
<div
|
||||||
>
|
key={row.label}
|
||||||
<div className="grid h-full w-full place-items-center rounded-full border border-border/70 bg-popover">
|
className="flex items-center justify-between gap-4 border-b border-border/60 px-4 py-3 last:border-b-0"
|
||||||
<div>
|
>
|
||||||
<p className="font-mono text-3xl font-semibold text-foreground">{percentage.toFixed(1)}%</p>
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-muted-foreground">context</p>
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">{row.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 font-mono text-sm font-semibold text-foreground">{row.value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
<p className="mt-4 text-sm text-muted-foreground">
|
|
||||||
{formatNumber(used)} of {formatNumber(total)} tokens used
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<MetricCard label="Input" value={formatCurrency(data.cost?.input)} icon={Zap} />
|
<div>
|
||||||
<MetricCard label="Output" value={formatCurrency(data.cost?.output)} icon={Activity} />
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
||||||
<MetricCard label="Total" value={formatCurrency(totalCost)} icon={Coins} tone="primary" />
|
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
||||||
<MetricCard label="Input tokens" value={formatNumber(inputTokens)} icon={CommandIcon} />
|
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
||||||
<MetricCard label="Output tokens" value={formatNumber(outputTokens)} icon={TerminalSquare} />
|
|
||||||
<MetricCard label="Cache tokens" value={formatNumber(cacheTokens)} icon={Package} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border/70 bg-muted/20 p-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Provider</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold text-foreground">{provider}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Model</p>
|
|
||||||
<p className="mt-1 break-all font-mono text-sm text-foreground">{model}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs leading-5 text-muted-foreground">
|
|
||||||
Cost is an estimate based on the available token counters and default provider rates.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -636,8 +612,8 @@ export default function CommandResultModal({
|
|||||||
},
|
},
|
||||||
cost: {
|
cost: {
|
||||||
eyebrow: 'Session telemetry',
|
eyebrow: 'Session telemetry',
|
||||||
title: 'Usage & Cost',
|
title: 'Token Usage',
|
||||||
subtitle: 'Token budget, context pressure, and estimated spend for this session.',
|
subtitle: 'Input, output, and total token counts for this session.',
|
||||||
icon: Coins,
|
icon: Coins,
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
type TokenUsagePieProps = {
|
|
||||||
used: number;
|
|
||||||
total: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TokenUsagePie({ used, total }: TokenUsagePieProps) {
|
|
||||||
// Token usage visualization component
|
|
||||||
// Only bail out on missing values or non‐positive totals; allow used===0 to render 0%
|
|
||||||
if (used == null || total == null || total <= 0) return null;
|
|
||||||
|
|
||||||
const percentage = Math.min(100, (used / total) * 100);
|
|
||||||
const radius = 10;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const offset = circumference - (percentage / 100) * circumference;
|
|
||||||
|
|
||||||
// Color based on usage level
|
|
||||||
const getColor = () => {
|
|
||||||
if (percentage < 50) return '#3b82f6'; // blue
|
|
||||||
if (percentage < 75) return '#f59e0b'; // orange
|
|
||||||
return '#ef4444'; // red
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" className="-rotate-90 transform">
|
|
||||||
{/* Background circle */}
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-gray-300 dark:text-gray-600"
|
|
||||||
/>
|
|
||||||
{/* Progress circle */}
|
|
||||||
<circle
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r={radius}
|
|
||||||
fill="none"
|
|
||||||
stroke={getColor()}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
|
||||||
{percentage.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
53
src/components/chat/view/subcomponents/TokenUsageSummary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ActivityIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
type TokenUsageSummaryProps = {
|
||||||
|
usage: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTokenCount = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 10_000) {
|
||||||
|
return `${Math.round(value / 1_000)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${(value / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const readUsageNumber = (value: unknown) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TokenUsageSummary({ usage }: TokenUsageSummaryProps) {
|
||||||
|
const breakdown =
|
||||||
|
usage?.breakdown && typeof usage.breakdown === 'object'
|
||||||
|
? usage.breakdown as Record<string, unknown>
|
||||||
|
: null;
|
||||||
|
const inputTokens = readUsageNumber(usage?.inputTokens ?? breakdown?.input);
|
||||||
|
const outputTokens = readUsageNumber(usage?.outputTokens ?? breakdown?.output);
|
||||||
|
const usedTokens = readUsageNumber(usage?.used) || inputTokens + outputTokens;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-border/70 bg-background/70 px-2 text-xs text-muted-foreground shadow-sm transition-colors hover:border-primary/25 hover:text-foreground sm:gap-2 sm:px-2.5"
|
||||||
|
title={`${usedTokens.toLocaleString()} tokens used`}
|
||||||
|
>
|
||||||
|
<span className="grid h-5 w-5 place-items-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-foreground">{formatTokenCount(usedTokens)}</span>
|
||||||
|
<span className="hidden text-muted-foreground/70 sm:inline">tokens</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user