mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-30 00:05:33 +08:00
Compare commits
6 Commits
feature/up
...
fix/use-fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d7419a3c | ||
|
|
d0cc85e76b | ||
|
|
661b8bd137 | ||
|
|
b80c7105d4 | ||
|
|
6f8fd37ab0 | ||
|
|
997cf9fd1a |
@@ -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,32 @@
|
|||||||
// 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 parsedInputTokens = Number(tokens.input);
|
||||||
|
const parsedOutputTokens = Number(tokens.output);
|
||||||
|
const inputTokens = Number.isFinite(parsedInputTokens) ? parsedInputTokens : 0;
|
||||||
|
const outputTokens = Number.isFinite(parsedOutputTokens) ? parsedOutputTokens : 0;
|
||||||
|
const parsedUsed = Number(tokens.total);
|
||||||
|
const used = Number.isFinite(parsedUsed) ? parsedUsed : inputTokens + outputTokens;
|
||||||
|
if (!Number.isFinite(used) || 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 +87,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) {
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
|
|
||||||
import { query, type ModelInfo, type Options } from '@anthropic-ai/claude-agent-sdk';
|
|
||||||
|
|
||||||
import { sessionsDb } from '@/modules/database/index.js';
|
import { sessionsDb } from '@/modules/database/index.js';
|
||||||
import { resolveClaudeCodeExecutablePath } from '@/shared/claude-cli-path.js';
|
|
||||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||||
import type {
|
import type {
|
||||||
ProviderChangeActiveModelInput,
|
ProviderChangeActiveModelInput,
|
||||||
ProviderCurrentActiveModel,
|
ProviderCurrentActiveModel,
|
||||||
ProviderModelOption,
|
|
||||||
ProviderModelsDefinition,
|
ProviderModelsDefinition,
|
||||||
ProviderSessionActiveModelChange,
|
ProviderSessionActiveModelChange,
|
||||||
} from '@/shared/types.js';
|
} from '@/shared/types.js';
|
||||||
@@ -19,17 +15,29 @@ import {
|
|||||||
|
|
||||||
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: 'default', label: 'Default (recommended)' },
|
{
|
||||||
{ value: 'sonnet[1m]', label: 'Sonnet (1M context)' },
|
value: 'default',
|
||||||
{ value: 'opus', label: 'Opus' },
|
label: 'Default (recommended)',
|
||||||
{ value: 'opus[1m]', label: 'Opus (1M context)' },
|
description: 'Use the default model (currently Opus 4.7 (1M context)) · $5/$25 per Mtok',
|
||||||
{ value: 'haiku', label: 'Haiku' },
|
},
|
||||||
{ value: 'sonnet', label: 'sonnet' },
|
{
|
||||||
|
value: 'sonnet',
|
||||||
|
label: 'Sonnet',
|
||||||
|
description: 'Sonnet 4.6 · Best for everyday tasks · $3/$15 per Mtok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'sonnet[1m]',
|
||||||
|
label: 'Sonnet (1M context)',
|
||||||
|
description: 'Sonnet 4.6 for long sessions · $3/$15 per Mtok',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'haiku',
|
||||||
|
label: 'Haiku',
|
||||||
|
description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
DEFAULT: 'default',
|
DEFAULT: 'sonnet',
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClaudeModelQueryOptions = Pick<Options, 'env' | 'pathToClaudeCodeExecutable' | 'permissionMode'>;
|
|
||||||
type ClaudeInitEvent = {
|
type ClaudeInitEvent = {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
@@ -49,46 +57,6 @@ const ANSI_PATTERN = new RegExp(
|
|||||||
'g',
|
'g',
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildClaudeQueryOptions = (): ClaudeModelQueryOptions => ({
|
|
||||||
env: { ...process.env },
|
|
||||||
pathToClaudeCodeExecutable: resolveClaudeCodeExecutablePath(process.env.CLAUDE_CLI_PATH),
|
|
||||||
permissionMode: 'default',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapClaudeModel = (model: ModelInfo): ProviderModelOption => ({
|
|
||||||
value: model.value,
|
|
||||||
label: model.displayName || model.value,
|
|
||||||
description: model.description || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildClaudeModelsDefinition = (models: ModelInfo[]): ProviderModelsDefinition => {
|
|
||||||
const options: ProviderModelOption[] = [];
|
|
||||||
const seenValues = new Set<string>();
|
|
||||||
|
|
||||||
for (const model of models) {
|
|
||||||
const mappedModel = mapClaudeModel(model);
|
|
||||||
if (seenValues.has(mappedModel.value)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seenValues.add(mappedModel.value);
|
|
||||||
options.push(mappedModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.length === 0) {
|
|
||||||
return CLAUDE_FALLBACK_MODELS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultValue = options.find((option) => option.value === 'default')?.value
|
|
||||||
?? options[0]?.value
|
|
||||||
?? CLAUDE_FALLBACK_MODELS.DEFAULT;
|
|
||||||
|
|
||||||
return {
|
|
||||||
OPTIONS: options,
|
|
||||||
DEFAULT: defaultValue,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
|
const extractClaudeEventModel = (event: ClaudeInitEvent, sessionId: string): string | null => {
|
||||||
const eventSessionId = event.sessionId ?? event.session_id;
|
const eventSessionId = event.sessionId ?? event.session_id;
|
||||||
if (eventSessionId && eventSessionId !== sessionId) {
|
if (eventSessionId && eventSessionId !== sessionId) {
|
||||||
@@ -181,25 +149,18 @@ const readClaudeSessionModelFromJsonl = async (
|
|||||||
|
|
||||||
export class ClaudeProviderModels implements IProviderModels {
|
export class ClaudeProviderModels implements IProviderModels {
|
||||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||||
let queryInstance: ReturnType<typeof query> | null = null;
|
// claude creates a new jsonl file as a separate session for this request.
|
||||||
|
// As a result, it lists the workspace where this is invoked when it shouldn't.
|
||||||
try {
|
//
|
||||||
// The SDK exposes its runtime model catalog on the initialized query
|
// Disabled for now:
|
||||||
// instance, so we create a lightweight query and immediately close it
|
// const queryInstance = query({
|
||||||
// after reading the control-plane metadata.
|
// prompt: 'Get supported models',
|
||||||
queryInstance = query({
|
// options: buildClaudeQueryOptions(),
|
||||||
prompt: 'Get supported models',
|
// });
|
||||||
options: buildClaudeQueryOptions(),
|
// const supportedModels = await queryInstance.supportedModels();
|
||||||
});
|
// queryInstance.close();
|
||||||
|
// return buildClaudeModelsDefinition(supportedModels);
|
||||||
const supportedModels = await queryInstance.supportedModels();
|
return CLAUDE_FALLBACK_MODELS;
|
||||||
|
|
||||||
return buildClaudeModelsDefinition(supportedModels);
|
|
||||||
} catch {
|
|
||||||
return CLAUDE_FALLBACK_MODELS;
|
|
||||||
} finally {
|
|
||||||
queryInstance?.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
async getCurrentActiveModel(sessionId?: string): Promise<ProviderCurrentActiveModel> {
|
||||||
|
|||||||
@@ -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,66 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
try {
|
||||||
|
db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (db) {
|
||||||
|
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 +245,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,36 +282,14 @@ 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.
|
|
||||||
const inputTokens =
|
|
||||||
inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0
|
|
||||||
? inputTokensRaw + cacheTokens
|
|
||||||
: 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",
|
||||||
@@ -321,18 +298,15 @@ Custom commands can be created in:
|
|||||||
tokenUsage: {
|
tokenUsage: {
|
||||||
used,
|
used,
|
||||||
total,
|
total,
|
||||||
percentage,
|
|
||||||
},
|
|
||||||
tokenBreakdown: {
|
|
||||||
input: inputTokens,
|
|
||||||
output: outputTokens,
|
|
||||||
cache: cacheTokens,
|
|
||||||
},
|
|
||||||
cost: {
|
|
||||||
input: inputCost.toFixed(4),
|
|
||||||
output: outputCost.toFixed(4),
|
|
||||||
total: totalCost.toFixed(4),
|
|
||||||
},
|
},
|
||||||
|
...(hasTokenBreakdown
|
||||||
|
? {
|
||||||
|
tokenBreakdown: {
|
||||||
|
input: inputTokensRaw,
|
||||||
|
output: outputTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
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,71 @@ 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 hasBreakdown =
|
||||||
const outputTokens = Number(data.tokenBreakdown?.output ?? 0);
|
typeof data.tokenBreakdown?.input === 'number' ||
|
||||||
const cacheTokens = Number(data.tokenBreakdown?.cache ?? 0);
|
typeof data.tokenBreakdown?.output === 'number';
|
||||||
const totalCost = Number(data.cost?.total ?? 0);
|
const usageRows = [
|
||||||
|
{ label: 'Total tokens used', value: formatNumber(used), icon: Activity },
|
||||||
|
...(hasBreakdown
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Input tokens',
|
||||||
|
value: formatNumber(Number(data.tokenBreakdown?.input ?? 0)),
|
||||||
|
icon: TerminalSquare,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Output tokens',
|
||||||
|
value: formatNumber(Number(data.tokenBreakdown?.output ?? 0)),
|
||||||
|
icon: Coins,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
label: 'Breakdown',
|
||||||
|
value: 'Unavailable',
|
||||||
|
icon: TerminalSquare,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...(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 +631,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