mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-11 08:01:34 +00:00
Compare commits
2 Commits
refactor/r
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fce8ad0893 | ||
|
|
cc7f652044 |
@@ -585,7 +585,7 @@
|
||||
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
|
||||
|
||||
<h4>Response (Non-Streaming)</h4>
|
||||
<p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
|
||||
<p>JSON object containing session details and assistant messages only (filtered). Content-Type: <code>application/json</code></p>
|
||||
|
||||
<h4>Error Response</h4>
|
||||
<p>Returns error details with appropriate HTTP status code.</p>
|
||||
@@ -674,21 +674,10 @@ data: {"type":"done"}</code></pre>
|
||||
"type": "text",
|
||||
"text": "I've completed the task..."
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"input_tokens": 150,
|
||||
"output_tokens": 50
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"tokens": {
|
||||
"inputTokens": 150,
|
||||
"outputTokens": 50,
|
||||
"cacheReadTokens": 0,
|
||||
"cacheCreationTokens": 0,
|
||||
"totalTokens": 200
|
||||
},
|
||||
"projectPath": "/path/to/project",
|
||||
"branch": {
|
||||
"name": "fix-authentication-bug-abc123",
|
||||
|
||||
@@ -274,46 +274,6 @@ function transformMessage(sdkMessage) {
|
||||
return sdkMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts token usage from SDK result messages
|
||||
* @param {Object} resultMessage - SDK result message
|
||||
* @returns {Object|null} Token budget object or null
|
||||
*/
|
||||
function extractTokenBudget(resultMessage) {
|
||||
if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the first model's usage data
|
||||
const modelKey = Object.keys(resultMessage.modelUsage)[0];
|
||||
const modelData = resultMessage.modelUsage[modelKey];
|
||||
|
||||
if (!modelData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use cumulative tokens if available (tracks total for the session)
|
||||
// Otherwise fall back to per-request tokens
|
||||
const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
|
||||
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
|
||||
const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
|
||||
|
||||
// Use configured context window budget from environment (default 160000)
|
||||
// This is the user's budget limit, not the model's context window
|
||||
const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
|
||||
|
||||
// Token calc logged via token-budget WS event
|
||||
|
||||
return {
|
||||
used: totalUsed,
|
||||
total: contextWindow
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles image processing for SDK queries
|
||||
* Saves base64 images to temporary files and returns modified prompt with file paths
|
||||
@@ -657,18 +617,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
||||
}
|
||||
ws.send(msg);
|
||||
}
|
||||
|
||||
// Extract and send token budget updates from result messages
|
||||
if (message.type === 'result') {
|
||||
const models = Object.keys(message.modelUsage || {});
|
||||
if (models.length > 0) {
|
||||
// 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' }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up session on completion
|
||||
|
||||
188
server/index.js
188
server/index.js
@@ -2218,194 +2218,6 @@ app.post('/api/projects/:projectName/upload-images', authenticateToken, async (r
|
||||
}
|
||||
});
|
||||
|
||||
// Get token usage for a specific session
|
||||
app.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { projectName, sessionId } = req.params;
|
||||
const { provider = 'claude' } = req.query;
|
||||
const homeDir = os.homedir();
|
||||
|
||||
// Allow only safe characters in sessionId
|
||||
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
if (!safeSessionId || safeSessionId !== String(sessionId)) {
|
||||
return res.status(400).json({ error: 'Invalid sessionId' });
|
||||
}
|
||||
|
||||
// Handle Cursor sessions - they use SQLite and don't have token usage info
|
||||
if (provider === 'cursor') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Cursor sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Gemini sessions - they are raw logs in our current setup
|
||||
if (provider === 'gemini') {
|
||||
return res.json({
|
||||
used: 0,
|
||||
total: 0,
|
||||
breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },
|
||||
unsupported: true,
|
||||
message: 'Token usage tracking not available for Gemini sessions'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Codex sessions
|
||||
if (provider === 'codex') {
|
||||
const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');
|
||||
|
||||
// Find the session file by searching for the session ID
|
||||
const findSessionFile = async (dir) => {
|
||||
try {
|
||||
const entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const found = await findSessionFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sessionFilePath = await findSessionFile(codexSessionsDir);
|
||||
|
||||
if (!sessionFilePath) {
|
||||
return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });
|
||||
}
|
||||
|
||||
// Read and parse the Codex JSONL file
|
||||
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 totalTokens = 0;
|
||||
let contextWindow = 200000; // Default for Codex/OpenAI
|
||||
|
||||
// Find the latest token_count event with info (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Codex stores token info in event_msg with type: "token_count"
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const tokenInfo = entry.payload.info;
|
||||
if (tokenInfo.total_token_usage) {
|
||||
totalTokens = tokenInfo.total_token_usage.total_tokens || 0;
|
||||
}
|
||||
if (tokenInfo.model_context_window) {
|
||||
contextWindow = tokenInfo.model_context_window;
|
||||
}
|
||||
break; // Stop after finding the latest token count
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
used: totalTokens,
|
||||
total: contextWindow
|
||||
});
|
||||
}
|
||||
|
||||
// Handle Claude sessions (default)
|
||||
// Extract actual project path
|
||||
let projectPath;
|
||||
try {
|
||||
projectPath = await extractProjectDirectory(projectName);
|
||||
} catch (error) {
|
||||
console.error('Error extracting project directory:', error);
|
||||
return res.status(500).json({ error: 'Failed to determine project path' });
|
||||
}
|
||||
|
||||
// Construct the JSONL file path
|
||||
// Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
|
||||
// The encoding replaces any non-alphanumeric character (except -) with -
|
||||
const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);
|
||||
|
||||
const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
|
||||
|
||||
// Constrain to projectDir
|
||||
const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
return res.status(400).json({ error: 'Invalid path' });
|
||||
}
|
||||
|
||||
// Read and parse the JSONL file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = await fsPromises.readFile(jsonlPath, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'Session file not found', path: jsonlPath });
|
||||
}
|
||||
throw error; // Re-throw other errors to be caught by outer try-catch
|
||||
}
|
||||
const lines = fileContent.trim().split('\n');
|
||||
|
||||
const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
|
||||
const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;
|
||||
let inputTokens = 0;
|
||||
let cacheCreationTokens = 0;
|
||||
let cacheReadTokens = 0;
|
||||
|
||||
// Find the latest assistant message with usage data (scan from end)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const entry = JSON.parse(lines[i]);
|
||||
|
||||
// Only count assistant messages which have usage data
|
||||
if (entry.type === 'assistant' && entry.message?.usage) {
|
||||
const usage = entry.message.usage;
|
||||
|
||||
// Use token counts from latest assistant message only
|
||||
inputTokens = usage.input_tokens || 0;
|
||||
cacheCreationTokens = usage.cache_creation_input_tokens || 0;
|
||||
cacheReadTokens = usage.cache_read_input_tokens || 0;
|
||||
|
||||
break; // Stop after finding the latest assistant message
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip lines that can't be parsed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total context usage (excluding output_tokens, as per ccusage)
|
||||
const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
|
||||
res.json({
|
||||
used: totalUsed,
|
||||
total: contextWindow,
|
||||
breakdown: {
|
||||
input: inputTokens,
|
||||
cacheCreation: cacheCreationTokens,
|
||||
cacheRead: cacheReadTokens
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading session token usage:', error);
|
||||
res.status(500).json({ error: 'Failed to read session token usage' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React app for all other routes (excluding static files)
|
||||
app.get('*', (req, res) => {
|
||||
// Skip requests for static assets (files with extensions)
|
||||
|
||||
@@ -129,8 +129,7 @@ function transformCodexEvent(event) {
|
||||
|
||||
case 'turn.completed':
|
||||
return {
|
||||
type: 'turn_complete',
|
||||
usage: event.usage
|
||||
type: 'turn_complete'
|
||||
};
|
||||
|
||||
case 'turn.failed':
|
||||
@@ -279,12 +278,6 @@ export async function queryCodex(command, options = {}, ws) {
|
||||
error: terminalFailure
|
||||
});
|
||||
}
|
||||
|
||||
// Extract and send token usage if available (normalized to match Claude format)
|
||||
if (event.type === 'turn.completed' && event.usage) {
|
||||
const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);
|
||||
sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));
|
||||
}
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
|
||||
@@ -1618,7 +1618,6 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
}
|
||||
|
||||
const messages = [];
|
||||
let tokenUsage = null;
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const rl = readline.createInterface({
|
||||
input: fileStream,
|
||||
@@ -1647,17 +1646,6 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
// Extract token usage from token_count events (keep latest)
|
||||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
||||
const info = entry.payload.info;
|
||||
if (info.total_token_usage) {
|
||||
tokenUsage = {
|
||||
used: info.total_token_usage.total_tokens || 0,
|
||||
total: info.model_context_window || 200000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use event_msg.user_message for user-visible inputs.
|
||||
if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {
|
||||
messages.push({
|
||||
@@ -1820,11 +1808,10 @@ async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage
|
||||
};
|
||||
}
|
||||
|
||||
return { messages, tokenUsage };
|
||||
return { messages };
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
||||
|
||||
@@ -214,7 +214,6 @@ export const codexAdapter = {
|
||||
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
||||
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
||||
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
||||
const tokenUsage = result.tokenUsage || null;
|
||||
|
||||
const normalized = [];
|
||||
for (const raw of rawMessages) {
|
||||
@@ -242,7 +241,6 @@ export const codexAdapter = {
|
||||
hasMore,
|
||||
offset,
|
||||
limit,
|
||||
tokenUsage,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,14 +53,7 @@ export function normalizeMessage(raw, sessionId) {
|
||||
}
|
||||
|
||||
if (raw.type === 'result') {
|
||||
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||
if (raw.stats?.total_tokens) {
|
||||
msgs.push(createNormalizedMessage({
|
||||
sessionId, timestamp: ts, provider: PROVIDER,
|
||||
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
||||
}));
|
||||
}
|
||||
return msgs;
|
||||
return [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
||||
}
|
||||
|
||||
if (raw.type === 'error') {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
* - stream_end: (no extra fields)
|
||||
* - error: content
|
||||
* - complete: (no extra fields)
|
||||
* - status: text, tokens?, canInterrupt?
|
||||
* - status: text, canInterrupt?
|
||||
* - permission_request: requestId, toolName, input, context?
|
||||
* - permission_cancelled: requestId
|
||||
* - session_created: newSessionId
|
||||
@@ -66,7 +66,6 @@
|
||||
* @property {boolean} hasMore - Whether more messages exist before the current page
|
||||
* @property {number} offset - Current offset
|
||||
* @property {number|null} limit - Page size used
|
||||
* @property {object} [tokenUsage] - Token usage data (provider-specific)
|
||||
*/
|
||||
|
||||
// ─── Provider Adapter Interface ──────────────────────────────────────────────
|
||||
|
||||
@@ -546,7 +546,12 @@ class ResponseCollector {
|
||||
const parsed = JSON.parse(msg);
|
||||
// Only include claude-response messages with assistant type
|
||||
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
|
||||
assistantMessages.push(parsed.data);
|
||||
const assistantMessage = { ...parsed.data };
|
||||
if (assistantMessage.message?.usage) {
|
||||
assistantMessage.message = { ...assistantMessage.message };
|
||||
delete assistantMessage.message.usage;
|
||||
}
|
||||
assistantMessages.push(assistantMessage);
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, skip
|
||||
@@ -556,49 +561,6 @@ class ResponseCollector {
|
||||
|
||||
return assistantMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total tokens from all messages
|
||||
*/
|
||||
getTotalTokens() {
|
||||
let totalInput = 0;
|
||||
let totalOutput = 0;
|
||||
let totalCacheRead = 0;
|
||||
let totalCacheCreation = 0;
|
||||
|
||||
for (const msg of this.messages) {
|
||||
let data = msg;
|
||||
|
||||
// Parse if string
|
||||
if (typeof msg === 'string') {
|
||||
try {
|
||||
data = JSON.parse(msg);
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract usage from claude-response messages
|
||||
if (data && data.type === 'claude-response' && data.data) {
|
||||
const msgData = data.data;
|
||||
if (msgData.message && msgData.message.usage) {
|
||||
const usage = msgData.message.usage;
|
||||
totalInput += usage.input_tokens || 0;
|
||||
totalOutput += usage.output_tokens || 0;
|
||||
totalCacheRead += usage.cache_read_input_tokens || 0;
|
||||
totalCacheCreation += usage.cache_creation_input_tokens || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
inputTokens: totalInput,
|
||||
outputTokens: totalOutput,
|
||||
cacheReadTokens: totalCacheRead,
|
||||
cacheCreationTokens: totalCacheCreation,
|
||||
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
@@ -789,13 +751,6 @@ class ResponseCollector {
|
||||
* success: true,
|
||||
* sessionId: "session-123",
|
||||
* messages: [...], // Assistant messages only (filtered)
|
||||
* tokens: {
|
||||
* inputTokens: 150,
|
||||
* outputTokens: 50,
|
||||
* cacheReadTokens: 0,
|
||||
* cacheCreationTokens: 0,
|
||||
* totalTokens: 200
|
||||
* },
|
||||
* projectPath: "/path/to/project",
|
||||
* branch: { // Only if createBranch=true
|
||||
* name: "feature/xyz",
|
||||
@@ -1173,15 +1128,13 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
// Streaming mode: end the SSE stream
|
||||
writer.end();
|
||||
} else {
|
||||
// Non-streaming mode: send filtered messages and token summary as JSON
|
||||
// Non-streaming mode: send filtered messages as JSON
|
||||
const assistantMessages = writer.getAssistantMessages();
|
||||
const tokenSummary = writer.getTotalTokens();
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
sessionId: writer.getSessionId(),
|
||||
messages: assistantMessages,
|
||||
tokens: tokenSummary,
|
||||
projectPath: finalProjectPath
|
||||
};
|
||||
|
||||
|
||||
@@ -97,12 +97,6 @@ const builtInCommands = [
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/cost',
|
||||
description: 'Display token usage and cost information',
|
||||
namespace: 'builtin',
|
||||
metadata: { type: 'builtin' }
|
||||
},
|
||||
{
|
||||
name: '/memory',
|
||||
description: 'Open CLAUDE.md memory file for editing',
|
||||
@@ -209,86 +203,6 @@ Custom commands can be created in:
|
||||
};
|
||||
},
|
||||
|
||||
'/cost': async (args, context) => {
|
||||
const tokenUsage = context?.tokenUsage || {};
|
||||
const provider = context?.provider || 'claude';
|
||||
const model =
|
||||
context?.model ||
|
||||
(provider === 'cursor'
|
||||
? CURSOR_MODELS.DEFAULT
|
||||
: provider === 'codex'
|
||||
? CODEX_MODELS.DEFAULT
|
||||
: CLAUDE_MODELS.DEFAULT);
|
||||
|
||||
const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
|
||||
const total =
|
||||
Number(
|
||||
tokenUsage.total ??
|
||||
tokenUsage.contextWindow ??
|
||||
parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
|
||||
) || 160000;
|
||||
const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
|
||||
|
||||
const inputTokensRaw =
|
||||
Number(
|
||||
tokenUsage.inputTokens ??
|
||||
tokenUsage.input ??
|
||||
tokenUsage.cumulativeInputTokens ??
|
||||
tokenUsage.promptTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const outputTokens =
|
||||
Number(
|
||||
tokenUsage.outputTokens ??
|
||||
tokenUsage.output ??
|
||||
tokenUsage.cumulativeOutputTokens ??
|
||||
tokenUsage.completionTokens ??
|
||||
0,
|
||||
) || 0;
|
||||
const cacheTokens =
|
||||
Number(
|
||||
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 {
|
||||
type: 'builtin',
|
||||
action: 'cost',
|
||||
data: {
|
||||
tokenUsage: {
|
||||
used,
|
||||
total,
|
||||
percentage,
|
||||
},
|
||||
cost: {
|
||||
input: inputCost.toFixed(4),
|
||||
output: outputCost.toFixed(4),
|
||||
total: totalCost.toFixed(4),
|
||||
},
|
||||
model,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
'/status': async (args, context) => {
|
||||
// Read version from package.json
|
||||
const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
|
||||
|
||||
@@ -42,7 +42,6 @@ interface UseChatComposerStateArgs {
|
||||
geminiModel: string;
|
||||
isLoading: boolean;
|
||||
canAbortSession: boolean;
|
||||
tokenBudget: Record<string, unknown> | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
sendByCtrlEnter?: boolean;
|
||||
onSessionActive?: (sessionId?: string | null) => void;
|
||||
@@ -57,7 +56,7 @@ interface UseChatComposerStateArgs {
|
||||
rewindMessages: (count: number) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void;
|
||||
setIsUserScrolledUp: (isScrolledUp: boolean) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
}
|
||||
@@ -114,7 +113,6 @@ export function useChatComposerState({
|
||||
geminiModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
@@ -176,12 +174,6 @@ export function useChatComposerState({
|
||||
});
|
||||
break;
|
||||
|
||||
case 'cost': {
|
||||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||||
addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||||
addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });
|
||||
@@ -282,7 +274,6 @@ export function useChatComposerState({
|
||||
sessionId: currentSessionId,
|
||||
provider,
|
||||
model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,
|
||||
tokenUsage: tokenBudget,
|
||||
};
|
||||
|
||||
const response = await authenticatedFetch('/api/commands/execute', {
|
||||
@@ -339,7 +330,6 @@ export function useChatComposerState({
|
||||
provider,
|
||||
selectedProject,
|
||||
addMessage,
|
||||
tokenBudget,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -543,7 +533,6 @@ export function useChatComposerState({
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ type LatestChatMessage = {
|
||||
provider?: string;
|
||||
content?: string;
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
canInterrupt?: boolean;
|
||||
tokenBudget?: unknown;
|
||||
newSessionId?: string;
|
||||
aborted?: boolean;
|
||||
[key: string]: any;
|
||||
@@ -55,8 +53,7 @@ interface UseChatRealtimeHandlersArgs {
|
||||
setCurrentSessionId: (sessionId: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setCanAbortSession: (canAbort: boolean) => void;
|
||||
setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;
|
||||
setTokenBudget: (budget: Record<string, unknown> | null) => void;
|
||||
setClaudeStatus: (status: { text: string; can_interrupt: boolean } | null) => void;
|
||||
setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;
|
||||
pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;
|
||||
streamBufferRef: MutableRefObject<string>;
|
||||
@@ -85,7 +82,6 @@ export function useChatRealtimeHandlers({
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
@@ -140,7 +136,6 @@ export function useChatRealtimeHandlers({
|
||||
if (status) {
|
||||
const statusInfo = {
|
||||
text: status.text || 'Working...',
|
||||
tokens: status.tokens || 0,
|
||||
can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,
|
||||
};
|
||||
setClaudeStatus(statusInfo);
|
||||
@@ -311,7 +306,7 @@ export function useChatRealtimeHandlers({
|
||||
});
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });
|
||||
setClaudeStatus({ text: 'Waiting for permission', can_interrupt: true });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -323,12 +318,9 @@ export function useChatRealtimeHandlers({
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
if (msg.text === 'token_budget' && msg.tokenBudget) {
|
||||
setTokenBudget(msg.tokenBudget as Record<string, unknown>);
|
||||
} else if (msg.text) {
|
||||
if (msg.text) {
|
||||
setClaudeStatus({
|
||||
text: msg.text,
|
||||
tokens: msg.tokens || 0,
|
||||
can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,
|
||||
});
|
||||
setIsLoading(true);
|
||||
@@ -352,7 +344,6 @@ export function useChatRealtimeHandlers({
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
import type { ChatMessage, Provider } from '../types/types';
|
||||
import type { Project, ProjectSession, SessionProvider } from '../../../types/app';
|
||||
import { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';
|
||||
@@ -108,9 +107,8 @@ export function useChatSessionState({
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);
|
||||
const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);
|
||||
const [claudeStatus, setClaudeStatus] = useState<{ text: string; can_interrupt: boolean } | null>(null);
|
||||
const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);
|
||||
const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);
|
||||
const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);
|
||||
@@ -319,7 +317,6 @@ export function useChatSessionState({
|
||||
messagesOffsetRef.current = 0;
|
||||
setHasMoreMessages(false);
|
||||
setTotalMessages(0);
|
||||
setTokenBudget(null);
|
||||
lastLoadedSessionKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
@@ -355,7 +352,6 @@ export function useChatSessionState({
|
||||
if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);
|
||||
|
||||
if (sessionChanged) {
|
||||
setTokenBudget(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -383,7 +379,6 @@ export function useChatSessionState({
|
||||
if (slot) {
|
||||
setHasMoreMessages(slot.hasMore);
|
||||
setTotalMessages(slot.total);
|
||||
if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);
|
||||
}
|
||||
setIsLoadingSessionMessages(false);
|
||||
}).catch(() => {
|
||||
@@ -539,31 +534,6 @@ export function useChatSessionState({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages.length, isLoadingSessionMessages, searchTarget]);
|
||||
|
||||
// Token usage fetch for Claude
|
||||
useEffect(() => {
|
||||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||||
setTokenBudget(null);
|
||||
return;
|
||||
}
|
||||
const sessionProvider = selectedSession.__provider || 'claude';
|
||||
if (sessionProvider !== 'claude') return;
|
||||
|
||||
const fetchInitialTokenUsage = async () => {
|
||||
try {
|
||||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||||
const response = await authenticatedFetch(url);
|
||||
if (response.ok) {
|
||||
setTokenBudget(await response.json());
|
||||
} else {
|
||||
setTokenBudget(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch initial token usage:', error);
|
||||
}
|
||||
};
|
||||
fetchInitialTokenUsage();
|
||||
}, [selectedProject, selectedSession?.id, selectedSession?.__provider]);
|
||||
|
||||
const visibleMessages = useMemo(() => {
|
||||
if (chatMessages.length <= visibleMessageCount) return chatMessages;
|
||||
return chatMessages.slice(-visibleMessageCount);
|
||||
@@ -713,8 +683,6 @@ export function useChatSessionState({
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
setTokenBudget,
|
||||
visibleMessageCount,
|
||||
visibleMessages,
|
||||
loadEarlierMessages,
|
||||
|
||||
@@ -96,8 +96,6 @@ function ChatInterface({
|
||||
setCanAbortSession,
|
||||
isUserScrolledUp,
|
||||
setIsUserScrolledUp,
|
||||
tokenBudget,
|
||||
setTokenBudget,
|
||||
visibleMessageCount,
|
||||
visibleMessages,
|
||||
loadEarlierMessages,
|
||||
@@ -183,7 +181,6 @@ function ChatInterface({
|
||||
geminiModel,
|
||||
isLoading,
|
||||
canAbortSession,
|
||||
tokenBudget,
|
||||
sendMessage,
|
||||
sendByCtrlEnter,
|
||||
onSessionActive,
|
||||
@@ -227,7 +224,6 @@ function ChatInterface({
|
||||
setIsLoading,
|
||||
setCanAbortSession,
|
||||
setClaudeStatus,
|
||||
setTokenBudget,
|
||||
setPendingPermissionRequests,
|
||||
pendingViewSessionRef,
|
||||
streamBufferRef,
|
||||
@@ -352,7 +348,6 @@ function ChatInterface({
|
||||
onModeSwitch={cyclePermissionMode}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={handleToggleCommandMenu}
|
||||
hasInput={Boolean(input.trim())}
|
||||
|
||||
@@ -41,7 +41,7 @@ interface ChatComposerProps {
|
||||
decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },
|
||||
) => void;
|
||||
handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };
|
||||
claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;
|
||||
claudeStatus: { text: string; can_interrupt: boolean } | null;
|
||||
isLoading: boolean;
|
||||
onAbortSession: () => void;
|
||||
provider: Provider | string;
|
||||
@@ -49,7 +49,6 @@ interface ChatComposerProps {
|
||||
onModeSwitch: () => void;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: Dispatch<SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -106,7 +105,6 @@ export default function ChatComposer({
|
||||
onModeSwitch,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
@@ -194,7 +192,6 @@ export default function ChatComposer({
|
||||
provider={provider}
|
||||
thinkingMode={thinkingMode}
|
||||
setThinkingMode={setThinkingMode}
|
||||
tokenBudget={tokenBudget}
|
||||
slashCommandsCount={slashCommandsCount}
|
||||
onToggleCommandMenu={onToggleCommandMenu}
|
||||
hasInput={hasInput}
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PermissionMode, Provider } from '../../types/types';
|
||||
import ThinkingModeSelector from './ThinkingModeSelector';
|
||||
import TokenUsagePie from './TokenUsagePie';
|
||||
|
||||
interface ChatInputControlsProps {
|
||||
permissionMode: PermissionMode | string;
|
||||
@@ -10,7 +9,6 @@ interface ChatInputControlsProps {
|
||||
provider: Provider | string;
|
||||
thinkingMode: string;
|
||||
setThinkingMode: React.Dispatch<React.SetStateAction<string>>;
|
||||
tokenBudget: { used?: number; total?: number } | null;
|
||||
slashCommandsCount: number;
|
||||
onToggleCommandMenu: () => void;
|
||||
hasInput: boolean;
|
||||
@@ -26,7 +24,6 @@ export default function ChatInputControls({
|
||||
provider,
|
||||
thinkingMode,
|
||||
setThinkingMode,
|
||||
tokenBudget,
|
||||
slashCommandsCount,
|
||||
onToggleCommandMenu,
|
||||
hasInput,
|
||||
@@ -78,8 +75,6 @@ export default function ChatInputControls({
|
||||
<ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className="" />
|
||||
)}
|
||||
|
||||
<TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCommandMenu}
|
||||
|
||||
@@ -6,7 +6,6 @@ import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'
|
||||
type ClaudeStatusProps = {
|
||||
status: {
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
can_interrupt?: boolean;
|
||||
} | null;
|
||||
onAbort?: () => void;
|
||||
@@ -126,4 +125,4 @@ export default function ClaudeStatus({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -46,9 +46,7 @@ export interface NormalizedMessage {
|
||||
toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;
|
||||
isError?: boolean;
|
||||
text?: string;
|
||||
tokens?: number;
|
||||
canInterrupt?: boolean;
|
||||
tokenBudget?: unknown;
|
||||
requestId?: string;
|
||||
input?: unknown;
|
||||
context?: unknown;
|
||||
@@ -81,7 +79,6 @@ export interface SessionSlot {
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
offset: number;
|
||||
tokenUsage: unknown;
|
||||
}
|
||||
|
||||
const EMPTY: NormalizedMessage[] = [];
|
||||
@@ -98,7 +95,6 @@ function createEmptySlot(): SessionSlot {
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
offset: 0,
|
||||
tokenUsage: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,9 +204,6 @@ export function useSessionStore() {
|
||||
slot.fetchedAt = Date.now();
|
||||
slot.status = 'idle';
|
||||
recomputeMergedIfNeeded(slot);
|
||||
if (data.tokenUsage) {
|
||||
slot.tokenUsage = data.tokenUsage;
|
||||
}
|
||||
|
||||
notify(sessionId);
|
||||
return slot;
|
||||
|
||||
Reference in New Issue
Block a user