fix: persist accumulated cost in session DB to survive reload (#9191)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2026-05-14 12:57:06 -04:00
committed by GitHub
parent c938a46417
commit 4671053985
13 changed files with 100 additions and 192 deletions
+1
View File
@@ -164,6 +164,7 @@ pub async fn get_token_state(session_manager: &SessionManager, session_id: &str)
accumulated_input_tokens: session.accumulated_input_tokens.unwrap_or(0), accumulated_input_tokens: session.accumulated_input_tokens.unwrap_or(0),
accumulated_output_tokens: session.accumulated_output_tokens.unwrap_or(0), accumulated_output_tokens: session.accumulated_output_tokens.unwrap_or(0),
accumulated_total_tokens: session.accumulated_total_tokens.unwrap_or(0), accumulated_total_tokens: session.accumulated_total_tokens.unwrap_or(0),
accumulated_cost: session.accumulated_cost,
}) })
.inspect_err(|e| { .inspect_err(|e| {
tracing::warn!( tracing::warn!(
+1
View File
@@ -4293,6 +4293,7 @@ print(\"hello, world\")
accumulated_total_tokens, accumulated_total_tokens,
accumulated_input_tokens, accumulated_input_tokens,
accumulated_output_tokens, accumulated_output_tokens,
accumulated_cost: None,
schedule_id: None, schedule_id: None,
recipe: None, recipe: None,
user_recipe_values: None, user_recipe_values: None,
+27
View File
@@ -527,6 +527,12 @@ impl Agent {
let accumulated_output = let accumulated_output =
accumulate(session.accumulated_output_tokens, usage.usage.output_tokens); accumulate(session.accumulated_output_tokens, usage.usage.output_tokens);
let accumulated_cost = session
.provider_name
.as_deref()
.and_then(|pn| self.accumulate_cost(session.accumulated_cost, usage, pn))
.or(session.accumulated_cost);
let (current_total, current_input, current_output) = if is_compaction_usage { let (current_total, current_input, current_output) = if is_compaction_usage {
// After compaction: summary output becomes new input context // After compaction: summary output becomes new input context
let new_input = usage.usage.output_tokens; let new_input = usage.usage.output_tokens;
@@ -548,11 +554,32 @@ impl Agent {
.accumulated_total_tokens(accumulated_total) .accumulated_total_tokens(accumulated_total)
.accumulated_input_tokens(accumulated_input) .accumulated_input_tokens(accumulated_input)
.accumulated_output_tokens(accumulated_output) .accumulated_output_tokens(accumulated_output)
.accumulated_cost(accumulated_cost)
.apply() .apply()
.await?; .await?;
Ok(()) Ok(())
} }
fn accumulate_cost(
&self,
existing: Option<f64>,
usage: &ProviderUsage,
provider_name: &str,
) -> Option<f64> {
let canonical =
crate::providers::canonical::maybe_get_canonical_model(provider_name, &usage.model)?;
let input_price = canonical.cost.input?;
let output_price = canonical.cost.output?;
let input_tokens = usage.usage.input_tokens.unwrap_or(0) as f64;
let output_tokens = usage.usage.output_tokens.unwrap_or(0) as f64;
let chunk_cost = (input_tokens * input_price + output_tokens * output_price) / 1_000_000.0;
Some(existing.unwrap_or(0.0) + chunk_cost)
}
} }
/// Check whether a tool should be callable by an app based on MCP Apps visibility metadata. /// Check whether a tool should be callable by an app based on MCP Apps visibility metadata.
+1
View File
@@ -1026,6 +1026,7 @@ pub struct TokenState {
pub accumulated_input_tokens: i32, pub accumulated_input_tokens: i32,
pub accumulated_output_tokens: i32, pub accumulated_output_tokens: i32,
pub accumulated_total_tokens: i32, pub accumulated_total_tokens: i32,
pub accumulated_cost: Option<f64>,
} }
#[cfg(test)] #[cfg(test)]
+35 -2
View File
@@ -19,7 +19,7 @@ use std::sync::{Arc, LazyLock};
use tracing::{info, warn}; use tracing::{info, warn};
use utoipa::ToSchema; use utoipa::ToSchema;
pub const CURRENT_SCHEMA_VERSION: i32 = 12; pub const CURRENT_SCHEMA_VERSION: i32 = 13;
pub const SESSIONS_FOLDER: &str = "sessions"; pub const SESSIONS_FOLDER: &str = "sessions";
pub const DB_NAME: &str = "sessions.db"; pub const DB_NAME: &str = "sessions.db";
@@ -72,6 +72,7 @@ pub struct Session {
pub accumulated_total_tokens: Option<i32>, pub accumulated_total_tokens: Option<i32>,
pub accumulated_input_tokens: Option<i32>, pub accumulated_input_tokens: Option<i32>,
pub accumulated_output_tokens: Option<i32>, pub accumulated_output_tokens: Option<i32>,
pub accumulated_cost: Option<f64>,
pub schedule_id: Option<String>, pub schedule_id: Option<String>,
pub recipe: Option<Recipe>, pub recipe: Option<Recipe>,
pub user_recipe_values: Option<HashMap<String, String>>, pub user_recipe_values: Option<HashMap<String, String>>,
@@ -101,6 +102,7 @@ pub struct SessionUpdateBuilder<'a> {
accumulated_total_tokens: Option<Option<i32>>, accumulated_total_tokens: Option<Option<i32>>,
accumulated_input_tokens: Option<Option<i32>>, accumulated_input_tokens: Option<Option<i32>>,
accumulated_output_tokens: Option<Option<i32>>, accumulated_output_tokens: Option<Option<i32>>,
accumulated_cost: Option<Option<f64>>,
schedule_id: Option<Option<String>>, schedule_id: Option<Option<String>>,
recipe: Option<Option<Recipe>>, recipe: Option<Option<Recipe>>,
user_recipe_values: Option<Option<HashMap<String, String>>>, user_recipe_values: Option<Option<HashMap<String, String>>>,
@@ -135,6 +137,7 @@ impl<'a> SessionUpdateBuilder<'a> {
accumulated_total_tokens: None, accumulated_total_tokens: None,
accumulated_input_tokens: None, accumulated_input_tokens: None,
accumulated_output_tokens: None, accumulated_output_tokens: None,
accumulated_cost: None,
schedule_id: None, schedule_id: None,
recipe: None, recipe: None,
user_recipe_values: None, user_recipe_values: None,
@@ -213,6 +216,11 @@ impl<'a> SessionUpdateBuilder<'a> {
self self
} }
pub fn accumulated_cost(mut self, cost: Option<f64>) -> Self {
self.accumulated_cost = Some(cost);
self
}
pub fn schedule_id(mut self, schedule_id: Option<String>) -> Self { pub fn schedule_id(mut self, schedule_id: Option<String>) -> Self {
self.schedule_id = Some(schedule_id); self.schedule_id = Some(schedule_id);
self self
@@ -490,6 +498,7 @@ impl Default for Session {
accumulated_total_tokens: None, accumulated_total_tokens: None,
accumulated_input_tokens: None, accumulated_input_tokens: None,
accumulated_output_tokens: None, accumulated_output_tokens: None,
accumulated_cost: None,
schedule_id: None, schedule_id: None,
recipe: None, recipe: None,
user_recipe_values: None, user_recipe_values: None,
@@ -557,6 +566,7 @@ impl sqlx::FromRow<'_, sqlx::sqlite::SqliteRow> for Session {
accumulated_total_tokens: row.try_get("accumulated_total_tokens")?, accumulated_total_tokens: row.try_get("accumulated_total_tokens")?,
accumulated_input_tokens: row.try_get("accumulated_input_tokens")?, accumulated_input_tokens: row.try_get("accumulated_input_tokens")?,
accumulated_output_tokens: row.try_get("accumulated_output_tokens")?, accumulated_output_tokens: row.try_get("accumulated_output_tokens")?,
accumulated_cost: row.try_get("accumulated_cost").ok().flatten(),
schedule_id: row.try_get("schedule_id")?, schedule_id: row.try_get("schedule_id")?,
recipe, recipe,
user_recipe_values, user_recipe_values,
@@ -666,6 +676,7 @@ impl SessionStorage {
accumulated_total_tokens INTEGER, accumulated_total_tokens INTEGER,
accumulated_input_tokens INTEGER, accumulated_input_tokens INTEGER,
accumulated_output_tokens INTEGER, accumulated_output_tokens INTEGER,
accumulated_cost REAL,
schedule_id TEXT, schedule_id TEXT,
recipe_json TEXT, recipe_json TEXT,
user_recipe_values_json TEXT, user_recipe_values_json TEXT,
@@ -786,9 +797,10 @@ impl SessionStorage {
id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data, id, name, user_set_name, session_type, working_dir, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens, total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
accumulated_cost,
schedule_id, recipe_json, user_recipe_values_json, schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json, goose_mode provider_name, model_config_json, goose_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#, "#,
) )
.bind(&session.id) .bind(&session.id)
@@ -805,6 +817,7 @@ impl SessionStorage {
.bind(session.accumulated_total_tokens) .bind(session.accumulated_total_tokens)
.bind(session.accumulated_input_tokens) .bind(session.accumulated_input_tokens)
.bind(session.accumulated_output_tokens) .bind(session.accumulated_output_tokens)
.bind(session.accumulated_cost)
.bind(&session.schedule_id) .bind(&session.schedule_id)
.bind(recipe_json) .bind(recipe_json)
.bind(user_recipe_values_json) .bind(user_recipe_values_json)
@@ -1087,6 +1100,19 @@ impl SessionStorage {
.await?; .await?;
} }
} }
13 => {
let has_accumulated_cost = sqlx::query_scalar::<_, i32>(
"SELECT COUNT(*) FROM pragma_table_info('sessions') WHERE name = 'accumulated_cost'",
)
.fetch_one(&mut **tx)
.await?
> 0;
if !has_accumulated_cost {
sqlx::query("ALTER TABLE sessions ADD COLUMN accumulated_cost REAL")
.execute(&mut **tx)
.await?;
}
}
_ => { _ => {
anyhow::bail!("Unknown migration version: {}", version); anyhow::bail!("Unknown migration version: {}", version);
} }
@@ -1147,6 +1173,7 @@ impl SessionStorage {
SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data, SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data,
total_tokens, input_tokens, output_tokens, total_tokens, input_tokens, output_tokens,
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens, accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
accumulated_cost,
schedule_id, recipe_json, user_recipe_values_json, schedule_id, recipe_json, user_recipe_values_json,
provider_name, model_config_json, goose_mode, provider_name, model_config_json, goose_mode,
archived_at, project_id archived_at, project_id
@@ -1207,6 +1234,7 @@ impl SessionStorage {
builder.accumulated_output_tokens, builder.accumulated_output_tokens,
"accumulated_output_tokens" "accumulated_output_tokens"
); );
add_update!(builder.accumulated_cost, "accumulated_cost");
add_update!(builder.schedule_id, "schedule_id"); add_update!(builder.schedule_id, "schedule_id");
add_update!(builder.recipe, "recipe_json"); add_update!(builder.recipe, "recipe_json");
add_update!(builder.user_recipe_values, "user_recipe_values_json"); add_update!(builder.user_recipe_values, "user_recipe_values_json");
@@ -1259,6 +1287,9 @@ impl SessionStorage {
if let Some(aot) = builder.accumulated_output_tokens { if let Some(aot) = builder.accumulated_output_tokens {
q = q.bind(aot); q = q.bind(aot);
} }
if let Some(ac) = builder.accumulated_cost {
q = q.bind(ac);
}
if let Some(sid) = builder.schedule_id { if let Some(sid) = builder.schedule_id {
q = q.bind(sid); q = q.bind(sid);
} }
@@ -1445,6 +1476,7 @@ impl SessionStorage {
SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data, SELECT s.id, s.working_dir, s.name, s.description, s.user_set_name, s.session_type, s.created_at, s.updated_at, s.extension_data,
s.total_tokens, s.input_tokens, s.output_tokens, s.total_tokens, s.input_tokens, s.output_tokens,
s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens, s.accumulated_total_tokens, s.accumulated_input_tokens, s.accumulated_output_tokens,
s.accumulated_cost,
s.schedule_id, s.recipe_json, s.user_recipe_values_json, s.schedule_id, s.recipe_json, s.user_recipe_values_json,
s.provider_name, s.model_config_json, s.goose_mode, s.provider_name, s.model_config_json, s.goose_mode,
s.archived_at, s.project_id, s.archived_at, s.project_id,
@@ -1564,6 +1596,7 @@ impl SessionStorage {
.accumulated_total_tokens(import.accumulated_total_tokens) .accumulated_total_tokens(import.accumulated_total_tokens)
.accumulated_input_tokens(import.accumulated_input_tokens) .accumulated_input_tokens(import.accumulated_input_tokens)
.accumulated_output_tokens(import.accumulated_output_tokens) .accumulated_output_tokens(import.accumulated_output_tokens)
.accumulated_cost(import.accumulated_cost)
.schedule_id(import.schedule_id) .schedule_id(import.schedule_id)
.recipe(import.recipe) .recipe(import.recipe)
.user_recipe_values(import.user_recipe_values); .user_recipe_values(import.user_recipe_values);
+10
View File
@@ -7868,6 +7868,11 @@
"message_count" "message_count"
], ],
"properties": { "properties": {
"accumulated_cost": {
"type": "number",
"format": "double",
"nullable": true
},
"accumulated_input_tokens": { "accumulated_input_tokens": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -8567,6 +8572,11 @@
"accumulatedTotalTokens" "accumulatedTotalTokens"
], ],
"properties": { "properties": {
"accumulatedCost": {
"type": "number",
"format": "double",
"nullable": true
},
"accumulatedInputTokens": { "accumulatedInputTokens": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
+2
View File
@@ -1272,6 +1272,7 @@ export type ScheduledJob = {
}; };
export type Session = { export type Session = {
accumulated_cost?: number | null;
accumulated_input_tokens?: number | null; accumulated_input_tokens?: number | null;
accumulated_output_tokens?: number | null; accumulated_output_tokens?: number | null;
accumulated_total_tokens?: number | null; accumulated_total_tokens?: number | null;
@@ -1480,6 +1481,7 @@ export type ThinkingContent = {
}; };
export type TokenState = { export type TokenState = {
accumulatedCost?: number | null;
accumulatedInputTokens: number; accumulatedInputTokens: number;
accumulatedOutputTokens: number; accumulatedOutputTokens: number;
accumulatedTotalTokens: number; accumulatedTotalTokens: number;
+4 -10
View File
@@ -28,7 +28,6 @@ import { RecipeHeader } from './RecipeHeader';
import { RecipeWarningModal } from './ui/RecipeWarningModal'; import { RecipeWarningModal } from './ui/RecipeWarningModal';
import { scanRecipe } from '../recipe'; import { scanRecipe } from '../recipe';
import { UserInput } from '../types/message'; import { UserInput } from '../types/message';
import { useCostTracking } from '../hooks/useCostTracking';
import RecipeActivities from './recipes/RecipeActivities'; import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount'; import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage, getTextAndImageContent } from '../types/message'; import { getThinkingMessage, getTextAndImageContent } from '../types/message';
@@ -196,14 +195,6 @@ export default function BaseChat({
handleSubmit(input); handleSubmit(input);
}; };
const { sessionCosts } = useCostTracking({
sessionInputTokens: session?.accumulated_input_tokens || 0,
sessionOutputTokens: session?.accumulated_output_tokens || 0,
localInputTokens: 0,
localOutputTokens: 0,
session,
});
const sessionModel = session?.model_config?.model_name ?? null; const sessionModel = session?.model_config?.model_name ?? null;
const sessionProvider = session?.provider_name ?? null; const sessionProvider = session?.provider_name ?? null;
const sessionLoaded = session !== undefined; const sessionLoaded = session !== undefined;
@@ -511,11 +502,14 @@ export default function BaseChat({
accumulatedOutputTokens={ accumulatedOutputTokens={
tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined
} }
accumulatedCost={
tokenState?.accumulatedCost ?? session?.accumulated_cost ?? undefined
}
droppedFiles={droppedFiles} droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages} messages={messages}
disableAnimation={disableAnimation} disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
recipe={recipe} recipe={recipe}
recipeAccepted={!hasNotAcceptedRecipe} recipeAccepted={!hasNotAcceptedRecipe}
initialPrompt={initialPrompt} initialPrompt={initialPrompt}
+3 -9
View File
@@ -167,14 +167,8 @@ interface ChatInputProps {
totalTokens?: number; totalTokens?: number;
accumulatedInputTokens?: number; accumulatedInputTokens?: number;
accumulatedOutputTokens?: number; accumulatedOutputTokens?: number;
accumulatedCost?: number | null;
messages?: Message[]; messages?: Message[];
sessionCosts?: {
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
};
disableAnimation?: boolean; disableAnimation?: boolean;
recipe?: Recipe | null; recipe?: Recipe | null;
recipeId?: string | null; recipeId?: string | null;
@@ -203,9 +197,9 @@ export default function ChatInput({
totalTokens, totalTokens,
accumulatedInputTokens, accumulatedInputTokens,
accumulatedOutputTokens, accumulatedOutputTokens,
accumulatedCost,
messages = [], messages = [],
disableAnimation = false, disableAnimation = false,
sessionCosts,
recipe, recipe,
recipeId, recipeId,
recipeAccepted, recipeAccepted,
@@ -1690,7 +1684,7 @@ export default function ChatInput({
<CostTracker <CostTracker
inputTokens={accumulatedInputTokens} inputTokens={accumulatedInputTokens}
outputTokens={accumulatedOutputTokens} outputTokens={accumulatedOutputTokens}
sessionCosts={sessionCosts} accumulatedCost={accumulatedCost}
model={effectiveModel} model={effectiveModel}
provider={effectiveProvider} provider={effectiveProvider}
/> />
-1
View File
@@ -107,7 +107,6 @@ export default function Hub({
onFilesProcessed={() => {}} onFilesProcessed={() => {}}
messages={[]} messages={[]}
disableAnimation={false} disableAnimation={false}
sessionCosts={undefined}
toolCount={0} toolCount={0}
onWorkingDirChange={setWorkingDir} onWorkingDirChange={setWorkingDir}
inputRef={inputRef} inputRef={inputRef}
@@ -14,10 +14,6 @@ const i18n = defineMessages({
id: 'costTracker.costUnavailable', id: 'costTracker.costUnavailable',
defaultMessage: 'Cost data not available for {model} ({inputTokens} input, {outputTokens} output tokens)', defaultMessage: 'Cost data not available for {model} ({inputTokens} input, {outputTokens} output tokens)',
}, },
sessionCostBreakdown: {
id: 'costTracker.sessionCostBreakdown',
defaultMessage: 'Session cost breakdown:',
},
totalSessionCost: { totalSessionCost: {
id: 'costTracker.totalSessionCost', id: 'costTracker.totalSessionCost',
defaultMessage: 'Total session cost: {cost}', defaultMessage: 'Total session cost: {cost}',
@@ -31,13 +27,7 @@ const i18n = defineMessages({
interface CostTrackerProps { interface CostTrackerProps {
inputTokens?: number; inputTokens?: number;
outputTokens?: number; outputTokens?: number;
sessionCosts?: { accumulatedCost?: number | null;
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
};
model: string | null; model: string | null;
provider: string | null; provider: string | null;
} }
@@ -45,7 +35,7 @@ interface CostTrackerProps {
export function CostTracker({ export function CostTracker({
inputTokens = 0, inputTokens = 0,
outputTokens = 0, outputTokens = 0,
sessionCosts, accumulatedCost,
model: currentModel, model: currentModel,
provider: currentProvider, provider: currentProvider,
}: CostTrackerProps) { }: CostTrackerProps) {
@@ -106,41 +96,7 @@ export function CostTracker({
} }
const calculateCost = (): number => { const calculateCost = (): number => {
// If we have session costs, calculate the total across all models return accumulatedCost ?? 0;
if (sessionCosts) {
let totalCost = 0;
// Add up all historical costs from different models
Object.values(sessionCosts).forEach((modelCost) => {
totalCost += modelCost.totalCost;
});
// Add current model cost if we have pricing info
if (
costInfo &&
(costInfo.input_token_cost !== undefined || costInfo.output_token_cost !== undefined)
) {
const currentInputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
const currentOutputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
totalCost += currentInputCost + currentOutputCost;
}
return totalCost;
}
// Fallback to simple calculation for current model only
if (
!costInfo ||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined)
) {
return 0;
}
const inputCost = (inputTokens * (costInfo.input_token_cost || 0)) / 1_000_000;
const outputCost = (outputTokens * (costInfo.output_token_cost || 0)) / 1_000_000;
const total = inputCost + outputCost;
return total;
}; };
const formatCost = (cost: number): string => { const formatCost = (cost: number): string => {
@@ -165,10 +121,10 @@ export function CostTracker({
); );
} }
// If no cost info found, try to return a default
if ( if (
!costInfo || accumulatedCost == null &&
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined) (!costInfo ||
(costInfo.input_token_cost === undefined && costInfo.output_token_cost === undefined))
) { ) {
const freeProviders = ['ollama', 'local', 'localhost']; const freeProviders = ['ollama', 'local', 'localhost'];
if (freeProviders.includes(currentProvider.toLowerCase())) { if (freeProviders.includes(currentProvider.toLowerCase())) {
@@ -216,38 +172,22 @@ export function CostTracker({
// Build tooltip content // Build tooltip content
const getTooltipContent = (): string => { const getTooltipContent = (): string => {
// Handle error states first
if (pricingFailed) { if (pricingFailed) {
return intl.formatMessage(i18n.pricingUnavailable, { model: `${currentProvider}/${currentModel}` }); return intl.formatMessage(i18n.pricingUnavailable, { model: `${currentProvider}/${currentModel}` });
} }
// Handle session costs const currency = costInfo?.currency || '$';
if (sessionCosts && Object.keys(sessionCosts).length > 0) {
// Show session breakdown
let tooltip = intl.formatMessage(i18n.sessionCostBreakdown) + '\n';
Object.entries(sessionCosts).forEach(([modelKey, cost]) => { if (accumulatedCost != null) {
const costStr = `${costInfo?.currency || '$'}${cost.totalCost.toFixed(6)}`; return intl.formatMessage(i18n.totalSessionCost, { cost: `${currency}${totalCost.toFixed(4)}` })
tooltip += `${modelKey}: ${costStr} (${cost.inputTokens.toLocaleString()} in, ${cost.outputTokens.toLocaleString()} out)\n`; + `\n` + intl.formatMessage(i18n.inputOutputTooltip, {
}); inputTokens: inputTokens.toLocaleString(),
inputCost: `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`,
// Add current model if it has costs outputTokens: outputTokens.toLocaleString(),
if (costInfo && (inputTokens > 0 || outputTokens > 0)) { outputCost: `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`,
const currentCost = });
(inputTokens * (costInfo.input_token_cost || 0) +
outputTokens * (costInfo.output_token_cost || 0)) /
1_000_000;
if (currentCost > 0) {
tooltip += `${currentProvider}/${currentModel} (current): ${costInfo.currency || '$'}${currentCost.toFixed(6)} (${inputTokens.toLocaleString()} in, ${outputTokens.toLocaleString()} out)\n`;
}
}
tooltip += '\n' + intl.formatMessage(i18n.totalSessionCost, { cost: `${costInfo?.currency || '$'}${totalCost.toFixed(6)}` });
return tooltip;
} }
// Default tooltip for single model
const currency = costInfo?.currency || '$';
const inputCostStr = `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`; const inputCostStr = `${currency}${((inputTokens * (costInfo?.input_token_cost || 0)) / 1_000_000).toFixed(6)}`;
const outputCostStr = `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`; const outputCostStr = `${currency}${((outputTokens * (costInfo?.output_token_cost || 0)) / 1_000_000).toFixed(6)}`;
return intl.formatMessage(i18n.inputOutputTooltip, { return intl.formatMessage(i18n.inputOutputTooltip, {
-92
View File
@@ -1,92 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { fetchCanonicalModelInfo } from '../utils/canonical';
import { Session } from '../api';
interface UseCostTrackingProps {
sessionInputTokens: number;
sessionOutputTokens: number;
localInputTokens: number;
localOutputTokens: number;
session?: Session | null;
}
export const useCostTracking = ({
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
session,
}: UseCostTrackingProps) => {
const [sessionCosts, setSessionCosts] = useState<{
[key: string]: {
inputTokens: number;
outputTokens: number;
totalCost: number;
};
}>({});
const currentModel = session?.model_config?.model_name ?? undefined;
const currentProvider = session?.provider_name ?? undefined;
const prevModelRef = useRef<string | undefined>(undefined);
const prevProviderRef = useRef<string | undefined>(undefined);
// Handle model changes and accumulate costs
useEffect(() => {
if (!currentModel || !currentProvider) return;
const handleModelChange = async () => {
if (
prevModelRef.current !== undefined &&
prevProviderRef.current !== undefined &&
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
) {
// Model/provider has changed, save the costs for the previous model
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
// Get pricing info for the previous model
const prevCostInfo = await fetchCanonicalModelInfo(
prevProviderRef.current,
prevModelRef.current
);
if (prevCostInfo) {
const prevInputCost =
((sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0)) /
1_000_000;
const prevOutputCost =
((sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0)) /
1_000_000;
const prevTotalCost = prevInputCost + prevOutputCost;
// Save the accumulated costs for this model
setSessionCosts((prev) => ({
...prev,
[prevKey]: {
inputTokens: sessionInputTokens || localInputTokens,
outputTokens: sessionOutputTokens || localOutputTokens,
totalCost: prevTotalCost,
},
}));
}
}
prevModelRef.current = currentModel || undefined;
prevProviderRef.current = currentProvider || undefined;
};
handleModelChange();
}, [
currentModel,
currentProvider,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
session,
]);
return {
sessionCosts,
};
};
+1 -3
View File
@@ -314,9 +314,7 @@
"costTracker.pricingUnavailable": { "costTracker.pricingUnavailable": {
"defaultMessage": "Pricing data unavailable for {model}" "defaultMessage": "Pricing data unavailable for {model}"
}, },
"costTracker.sessionCostBreakdown": {
"defaultMessage": "Session cost breakdown:"
},
"costTracker.totalSessionCost": { "costTracker.totalSessionCost": {
"defaultMessage": "Total session cost: {cost}" "defaultMessage": "Total session cost: {cost}"
}, },