diff --git a/CLAUDE.md b/CLAUDE.md index 25ee994..687714e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,6 +185,8 @@ Compare responses from multiple LLM models simultaneously via OpenRouter. Featur Streaming commands: `send_prompt`, `start_debate`, `continue_debate`, `add_models_to_tile`, `regenerate_response` +**Canvas response costs and caching:** OpenRouter's final streaming usage chunk provides the exact `cost_usd` persisted on each `ModelResponse` and `DebateResponse`; legacy sessions have no cost and show no label. Canvas OpenRouter requests include a stable session-and-model identifier so OpenRouter can apply compatible provider-side prompt caching to follow-up context without changing model routing. + ### Twin Identity, Constitution, And Decision Mirror Twin context mode is a native RAG path, not model-weight training. `frontend/src-tauri/src/commands/canvas/context.rs` assembles the model-facing prompt through `build_twin_context_prompt()`. diff --git a/frontend/src-tauri/src/commands/canvas/debate.rs b/frontend/src-tauri/src/commands/canvas/debate.rs index b07ac8c..a71dc9c 100644 --- a/frontend/src-tauri/src/commands/canvas/debate.rs +++ b/frontend/src-tauri/src/commands/canvas/debate.rs @@ -7,7 +7,7 @@ use crate::models::canvas::{ DebateStartRequest, PromptType, TilePosition, }; use crate::models::twin::TraceEventType; -use crate::services::openrouter::ChatMessage; +use crate::services::openrouter::{ChatMessage, StreamUpdate}; use crate::AppState; use chrono::Utc; use futures::StreamExt; @@ -15,6 +15,13 @@ use serde_json::json; use std::time::Duration; use tauri::State; +fn no_cost_stream(stream: S) -> std::pin::Pin> + Send>> +where + S: futures::Stream> + Send + 'static, +{ + Box::pin(stream.map(|result| result.map(|content| StreamUpdate { content, cost_usd: None }))) +} + /// Start a debate between models with streaming via Tauri events #[tauri::command] pub async fn start_debate( @@ -195,15 +202,7 @@ pub async fn start_debate( .chat_stream(&model_id, messages, None, Some(0.7)) .await; drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, - > - }) + result.map(no_cost_stream) } ModelProviderRoute::OpenRouter => { let openrouter = openrouter_arc.read().await; @@ -217,18 +216,11 @@ pub async fn start_debate( Some(reasoning_effort.as_str()), false, 5, + Some(&format!("canvas:{session_id}:{model_id}")), ) .await; drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box< - dyn futures::Stream> - + Send, - >, - > - }) + result.map(|stream| Box::pin(stream) as std::pin::Pin> + Send>>) } }; @@ -236,12 +228,17 @@ pub async fn start_debate( Ok(stream) => { let mut stream = stream; let mut full_content = String::new(); + let mut cost_usd = None; loop { match tokio::time::timeout(Duration::from_secs(60), stream.next()) .await { - Ok(Some(Ok(chunk))) => { + Ok(Some(Ok(update))) => { + if update.cost_usd.is_some() { + cost_usd = update.cost_usd; + } + let chunk = update.content; if !chunk.is_empty() { full_content.push_str(&chunk); let _ = window.emit( @@ -272,6 +269,7 @@ pub async fn start_debate( model_name, content: full_content, stance: None, + cost_usd: None, }; } Ok(None) => break, // Stream ended naturally @@ -291,6 +289,7 @@ pub async fn start_debate( model_name, content: full_content, stance: None, + cost_usd: None, }; } } @@ -303,6 +302,7 @@ pub async fn start_debate( debate_id: debate_id.clone(), model_id: model_id.clone(), round_number: round_num, + cost_usd, }, ); @@ -311,6 +311,7 @@ pub async fn start_debate( model_name, content: full_content, stance: None, + cost_usd, } } Err(e) => { @@ -331,6 +332,7 @@ pub async fn start_debate( debate_id: debate_id.clone(), model_id: model_id.clone(), round_number: round_num, + cost_usd: None, }, ); DebateResponse { @@ -338,6 +340,7 @@ pub async fn start_debate( model_name, content: e.to_string(), stance: None, + cost_usd: None, } } } @@ -537,12 +540,7 @@ pub async fn continue_debate( .chat_stream(&model_id, messages, None, Some(0.7)) .await; drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(no_cost_stream) } ModelProviderRoute::OpenRouter => { let openrouter = openrouter_arc.read().await; @@ -556,15 +554,11 @@ pub async fn continue_debate( Some(reasoning_effort.as_str()), false, 5, + Some(&format!("canvas:{session_id}:{model_id}")), ) .await; drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(|stream| Box::pin(stream) as std::pin::Pin> + Send>>) } }; @@ -572,11 +566,16 @@ pub async fn continue_debate( Ok(stream) => { let mut stream = stream; let mut full_content = String::new(); + let mut cost_usd = None; loop { match tokio::time::timeout(Duration::from_secs(60), stream.next()).await { - Ok(Some(Ok(chunk))) => { + Ok(Some(Ok(update))) => { + if update.cost_usd.is_some() { + cost_usd = update.cost_usd; + } + let chunk = update.content; if !chunk.is_empty() { full_content.push_str(&chunk); let _ = window.emit( @@ -607,6 +606,7 @@ pub async fn continue_debate( model_name, content: full_content, stance: None, + cost_usd: None, }; } Ok(None) => break, // Stream ended naturally @@ -626,6 +626,7 @@ pub async fn continue_debate( model_name, content: full_content, stance: None, + cost_usd: None, }; } } @@ -638,6 +639,7 @@ pub async fn continue_debate( debate_id: debate_id.clone(), model_id: model_id.clone(), round_number: round_num, + cost_usd, }, ); @@ -646,6 +648,7 @@ pub async fn continue_debate( model_name, content: full_content, stance: None, + cost_usd, } } Err(e) => { @@ -666,12 +669,14 @@ pub async fn continue_debate( debate_id: debate_id.clone(), model_id: model_id.clone(), round_number: round_num, + cost_usd: None, }, ); DebateResponse { model_id, model_name, content: e.to_string(), + cost_usd: None, stance: None, } } diff --git a/frontend/src-tauri/src/commands/canvas/streaming.rs b/frontend/src-tauri/src/commands/canvas/streaming.rs index 0fe482d..dc3adfe 100644 --- a/frontend/src-tauri/src/commands/canvas/streaming.rs +++ b/frontend/src-tauri/src/commands/canvas/streaming.rs @@ -10,6 +10,7 @@ use crate::models::twin::{ DecisionEpisodeCreate, PrimitiveDecisionAssessment, ReflectionCardCreate, TraceEventType, }; use crate::services::twin::TwinStore; +use crate::services::openrouter::StreamUpdate; use crate::AppState; use chrono::Utc; use futures::StreamExt; @@ -28,7 +29,14 @@ const LLM_NODE_HEIGHT: f64 = 200.0; const LLM_NODE_Y_STEP: f64 = 300.0; // height(200) + 100px gap for content overflow const LLM_NODE_X_GAP: f64 = 80.0; -type StreamedResponseUpdate = (String, String, ResponseStatus, Option); +type StreamedResponseUpdate = (String, String, ResponseStatus, Option, Option); + +fn no_cost_stream(stream: S) -> std::pin::Pin> + Send>> +where + S: futures::Stream> + Send + 'static, +{ + Box::pin(stream.map(|result| result.map(|content| StreamUpdate { content, cost_usd: None }))) +} /// Send a prompt to multiple models with streaming responses via Tauri events. /// Returns the tile_id immediately; actual responses stream via "canvas-stream" events. @@ -81,6 +89,7 @@ pub async fn send_prompt( status: ResponseStatus::Pending, error: None, tokens_used: None, + cost_usd: None, created_at: now, position: TilePosition { x: llm_start_x, @@ -274,12 +283,7 @@ pub async fn send_prompt( ) .await; drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(no_cost_stream) } ModelProviderRoute::OpenRouter => { let openrouter = openrouter_arc.read().await; @@ -293,15 +297,11 @@ pub async fn send_prompt( Some(reasoning_effort.as_str()), web_search, web_search_max_results, + Some(&format!("canvas:{session_id}:{model_id}")), ) .await; drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(|stream| Box::pin(stream) as std::pin::Pin> + Send>>) } }; @@ -309,11 +309,16 @@ pub async fn send_prompt( Ok(stream) => { let mut stream = stream; let mut full_content = String::new(); + let mut cost_usd = None; loop { match tokio::time::timeout(Duration::from_secs(60), stream.next()).await { - Ok(Some(Ok(chunk))) => { + Ok(Some(Ok(update))) => { + if update.cost_usd.is_some() { + cost_usd = update.cost_usd; + } + let chunk = update.content; if !chunk.is_empty() { full_content.push_str(&chunk); let _ = window.emit( @@ -341,6 +346,7 @@ pub async fn send_prompt( String::new(), ResponseStatus::Error, Some(error), + None, ); } Ok(None) => break, // Stream ended naturally @@ -358,6 +364,7 @@ pub async fn send_prompt( full_content, ResponseStatus::Error, Some(error), + None, ); } } @@ -369,12 +376,13 @@ pub async fn send_prompt( &tile_id, model_id, full_content, + cost_usd, ) } Err(e) => { let error = e.to_string(); emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - (model_id, String::new(), ResponseStatus::Error, Some(error)) + (model_id, String::new(), ResponseStatus::Error, Some(error), None) } } }); @@ -396,7 +404,7 @@ pub async fn send_prompt( Err(error) => { let model_ids: Vec = results .iter() - .map(|(model_id, _, _, _)| model_id.clone()) + .map(|(model_id, _, _, _, _)| model_id.clone()) .collect(); emit_persistence_error( &window, @@ -421,7 +429,7 @@ pub async fn send_prompt( if let Some(decision_episode_id) = decision_episode_id_for_reflection { let mut twin_store = twin_store_arc.write().await; - for (model_id, content, status, _) in &results { + for (model_id, content, status, _, _) in &results { if *status != ResponseStatus::Completed || content.trim().is_empty() { continue; } @@ -556,6 +564,7 @@ pub async fn add_models_to_tile( status: ResponseStatus::Pending, error: None, tokens_used: None, + cost_usd: None, created_at: now, position: TilePosition { x: llm_start_x, @@ -618,12 +627,7 @@ pub async fn add_models_to_tile( .chat_stream(&model_id, messages, system_prompt.as_deref(), Some(0.7)) .await; drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(no_cost_stream) } ModelProviderRoute::OpenRouter => { let openrouter = openrouter_arc.read().await; @@ -637,15 +641,11 @@ pub async fn add_models_to_tile( Some(reasoning_effort.as_str()), web_search, web_search_max_results, + Some(&format!("canvas:{session_id}:{model_id}")), ) .await; drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(|stream| Box::pin(stream) as std::pin::Pin> + Send>>) } }; @@ -653,11 +653,16 @@ pub async fn add_models_to_tile( Ok(stream) => { let mut stream = stream; let mut full_content = String::new(); + let mut cost_usd = None; loop { match tokio::time::timeout(Duration::from_secs(60), stream.next()).await { - Ok(Some(Ok(chunk))) => { + Ok(Some(Ok(update))) => { + if update.cost_usd.is_some() { + cost_usd = update.cost_usd; + } + let chunk = update.content; if !chunk.is_empty() { full_content.push_str(&chunk); let _ = window.emit( @@ -685,6 +690,7 @@ pub async fn add_models_to_tile( String::new(), ResponseStatus::Error, Some(error), + None, ); } Ok(None) => break, @@ -702,6 +708,7 @@ pub async fn add_models_to_tile( full_content, ResponseStatus::Error, Some(error), + None, ); } } @@ -713,12 +720,13 @@ pub async fn add_models_to_tile( &tile_id, model_id, full_content, + cost_usd, ) } Err(e) => { let error = e.to_string(); emit_canvas_error(&window, &session_id, &tile_id, &model_id, &error); - (model_id, String::new(), ResponseStatus::Error, Some(error)) + (model_id, String::new(), ResponseStatus::Error, Some(error), None) } } }); @@ -740,7 +748,7 @@ pub async fn add_models_to_tile( Err(error) => { let model_ids: Vec = results .iter() - .map(|(model_id, _, _, _)| model_id.clone()) + .map(|(model_id, _, _, _, _)| model_id.clone()) .collect(); emit_persistence_error(&window, &session_id, &tile_id, &model_ids, &error); false @@ -754,7 +762,7 @@ pub async fn add_models_to_tile( TraceEventType::ModelsAdded, json!({ "tile_id": tile_id.clone(), - "model_ids": results.iter().map(|(model_id, _, _, _)| model_id.clone()).collect::>(), + "model_ids": results.iter().map(|(model_id, _, _, _, _)| model_id.clone()).collect::>(), }), ) .await; @@ -829,6 +837,7 @@ pub async fn regenerate_response( "", ResponseStatus::Streaming, None, + None, ); } @@ -852,12 +861,7 @@ pub async fn regenerate_response( .chat_stream(&model_id, messages, system_prompt.as_deref(), Some(0.7)) .await; drop(ollama); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(no_cost_stream) } ModelProviderRoute::OpenRouter => { let openrouter = openrouter_arc.read().await; @@ -871,15 +875,11 @@ pub async fn regenerate_response( Some(reasoning_effort.as_str()), web_search, web_search_max_results, + Some(&format!("canvas:{session_id}:{model_id}")), ) .await; drop(openrouter); - result.map(|stream| { - Box::pin(stream) - as std::pin::Pin< - Box> + Send>, - > - }) + result.map(|stream| Box::pin(stream) as std::pin::Pin> + Send>>) } }; @@ -889,11 +889,16 @@ pub async fn regenerate_response( Ok(stream) => { let mut stream = stream; let mut full_content = String::new(); + let mut cost_usd = None; let mut final_error: Option = None; loop { match tokio::time::timeout(Duration::from_secs(60), stream.next()).await { - Ok(Some(Ok(chunk))) => { + Ok(Some(Ok(update))) => { + if update.cost_usd.is_some() { + cost_usd = update.cost_usd; + } + let chunk = update.content; if !chunk.is_empty() { full_content.push_str(&chunk); let _ = window.emit( @@ -932,7 +937,7 @@ pub async fn regenerate_response( if let Some(error) = final_error.as_deref() { emit_canvas_error(&window, &session_id, &tile_id, &model_id, error); } else { - emit_canvas_complete(&window, &session_id, &tile_id, &model_id); + emit_canvas_complete(&window, &session_id, &tile_id, &model_id, cost_usd); } (update.1, update.2) }; @@ -946,6 +951,7 @@ pub async fn regenerate_response( &final_content, final_status.clone(), final_error.as_deref(), + cost_usd, ) { emit_persistence_error( &window, @@ -983,6 +989,7 @@ pub async fn regenerate_response( "", ResponseStatus::Error, Some(error.as_str()), + None, ) { emit_persistence_error( &window, @@ -1055,7 +1062,7 @@ async fn append_model_result_traces( trigger: &str, results: &[StreamedResponseUpdate], ) { - for (model_id, content, status, error) in results { + for (model_id, content, status, error, _) in results { let event_type = if *status == ResponseStatus::Error { TraceEventType::ResponseErrored } else { @@ -1123,7 +1130,7 @@ fn emit_persistence_error( } } -fn emit_canvas_complete(window: &tauri::Window, session_id: &str, tile_id: &str, model_id: &str) { +fn emit_canvas_complete(window: &tauri::Window, session_id: &str, tile_id: &str, model_id: &str, cost_usd: Option) { let _ = window.emit( "canvas-stream", CanvasStreamEvent::Complete { @@ -1131,6 +1138,7 @@ fn emit_canvas_complete(window: &tauri::Window, session_id: &str, tile_id: &str, tile_id: tile_id.to_string(), model_id: model_id.to_string(), tokens_used: None, + cost_usd, }, ); } @@ -1141,16 +1149,17 @@ fn finalize_streamed_model_response( tile_id: &str, model_id: String, full_content: String, + cost_usd: Option, ) -> StreamedResponseUpdate { let update = classify_streamed_model_response(model_id, full_content); if let Some(error) = &update.3 { emit_canvas_error(window, session_id, tile_id, &update.0, error); } else { - emit_canvas_complete(window, session_id, tile_id, &update.0); + emit_canvas_complete(window, session_id, tile_id, &update.0, cost_usd); } - update + (update.0, update.1, update.2, update.3, cost_usd) } fn classify_streamed_model_response( @@ -1163,9 +1172,10 @@ fn classify_streamed_model_response( String::new(), ResponseStatus::Error, Some(EMPTY_MODEL_RESPONSE_ERROR.to_string()), + None, ) } else { - (model_id, full_content, ResponseStatus::Completed, None) + (model_id, full_content, ResponseStatus::Completed, None, None) } } diff --git a/frontend/src-tauri/src/commands/canvas/test_support.rs b/frontend/src-tauri/src/commands/canvas/test_support.rs index 48987ee..985fcb6 100644 --- a/frontend/src-tauri/src/commands/canvas/test_support.rs +++ b/frontend/src-tauri/src/commands/canvas/test_support.rs @@ -18,6 +18,7 @@ pub(super) fn build_response(model_id: &str, content: &str) -> ModelResponse { status: ResponseStatus::Completed, error: None, tokens_used: None, + cost_usd: None, created_at: chrono::Utc::now(), position: TilePosition::default(), } diff --git a/frontend/src-tauri/src/models/canvas.rs b/frontend/src-tauri/src/models/canvas.rs index aa8e668..62aae4e 100644 --- a/frontend/src-tauri/src/models/canvas.rs +++ b/frontend/src-tauri/src/models/canvas.rs @@ -229,6 +229,8 @@ pub struct ModelResponse { pub error: Option, #[serde(default)] pub tokens_used: Option, + #[serde(default)] + pub cost_usd: Option, pub created_at: DateTime, #[serde(default)] pub position: TilePosition, @@ -244,6 +246,7 @@ impl Default for ModelResponse { status: ResponseStatus::Pending, error: None, tokens_used: None, + cost_usd: None, created_at: Utc::now(), position: TilePosition { x: 0.0, @@ -323,6 +326,8 @@ pub struct DebateResponse { pub model_name: String, pub content: String, pub stance: Option, + #[serde(default)] + pub cost_usd: Option, } /// Request to create a new session @@ -557,6 +562,7 @@ pub enum CanvasStreamEvent { tile_id: String, model_id: String, tokens_used: Option, + cost_usd: Option, }, Error { session_id: String, @@ -598,6 +604,7 @@ pub enum CanvasStreamEvent { debate_id: String, model_id: String, round_number: u32, + cost_usd: Option, }, DebateError { session_id: String, diff --git a/frontend/src-tauri/src/services/canvas_store.rs b/frontend/src-tauri/src/services/canvas_store.rs index 7493224..c2ac44b 100644 --- a/frontend/src-tauri/src/services/canvas_store.rs +++ b/frontend/src-tauri/src/services/canvas_store.rs @@ -426,16 +426,18 @@ impl CanvasStore { String, crate::models::canvas::ResponseStatus, Option, + Option, )], ) -> Result<()> { let session = self.get_session_mut(session_id)?; if let Some(tile) = session.prompt_tiles.iter_mut().find(|t| t.id == tile_id) { - for (model_id, content, status, error) in updates { + for (model_id, content, status, error, cost_usd) in updates { if let Some(response) = tile.responses.get_mut(model_id) { response.content = content.clone(); response.status = status.clone(); response.error = error.clone(); + response.cost_usd = *cost_usd; } } } @@ -454,6 +456,7 @@ impl CanvasStore { content: &str, status: crate::models::canvas::ResponseStatus, error: Option<&str>, + cost_usd: Option, ) -> Result<()> { let session = self.get_session_mut(session_id)?; @@ -462,6 +465,7 @@ impl CanvasStore { response.content = content.to_string(); response.status = status; response.error = error.map(str::to_string); + response.cost_usd = cost_usd; } } diff --git a/frontend/src-tauri/src/services/openrouter.rs b/frontend/src-tauri/src/services/openrouter.rs index 290e2c3..002dcdf 100644 --- a/frontend/src-tauri/src/services/openrouter.rs +++ b/frontend/src-tauri/src/services/openrouter.rs @@ -15,6 +15,12 @@ pub struct OpenRouterService { api_key: String, } +#[derive(Debug, Clone, PartialEq)] +pub struct StreamUpdate { + pub content: String, + pub cost_usd: Option, +} + impl OpenRouterService { pub fn new(api_key: String) -> Self { Self { @@ -132,6 +138,7 @@ impl OpenRouterService { max_tokens, reasoning, plugins, + session_id: None, }; let response = self @@ -174,7 +181,8 @@ impl OpenRouterService { reasoning_effort: Option<&str>, web_search: bool, web_search_max_results: u32, - ) -> Result>> { + session_id: Option<&str>, + ) -> Result>> { if !self.is_configured() { return Err(anyhow::anyhow!("OpenRouter API key not configured")); } @@ -201,6 +209,7 @@ impl OpenRouterService { max_tokens, reasoning, plugins, + session_id: session_id.map(str::to_string), }; let response = self @@ -257,8 +266,8 @@ impl OpenRouterService { if !buffer.trim().is_empty() { let remaining = std::mem::take(&mut buffer); match parse_sse_chunk(&remaining) { - Ok(content) if !content.is_empty() => { - return Some((Ok(content), (inner, buffer, utf8_buffer))); + Ok(update) if !update.content.is_empty() || update.cost_usd.is_some() => { + return Some((Ok(update), (inner, buffer, utf8_buffer))); } Err(error) => { return Some((Err(error), (inner, buffer, utf8_buffer))); @@ -308,8 +317,8 @@ fn build_reasoning(reasoning_effort: Option<&str>) -> Option { /// classified as a normal `Completed` stream with no indication anything /// went wrong. Such payloads are now detected and surfaced as an `Err` so /// the caller can mark the response as errored instead. -fn parse_sse_chunk(chunk: &str) -> Result { - let mut content = String::new(); +fn parse_sse_chunk(chunk: &str) -> Result { + let mut update = StreamUpdate { content: String::new(), cost_usd: None }; for line in chunk.lines() { if let Some(data) = line.strip_prefix("data: ") { @@ -320,9 +329,12 @@ fn parse_sse_chunk(chunk: &str) -> Result { if let Ok(parsed) = serde_json::from_str::(data) { if let Some(choice) = parsed.choices.first() { if let Some(delta_content) = &choice.delta.content { - content.push_str(delta_content); + update.content.push_str(delta_content); } } + if let Some(usage) = parsed.usage { + update.cost_usd = usage.cost; + } continue; } @@ -336,7 +348,7 @@ fn parse_sse_chunk(chunk: &str) -> Result { } } - Ok(content) + Ok(update) } /// Extract provider name from model ID (e.g., "openai/gpt-4" -> "OpenAI") @@ -432,6 +444,8 @@ struct ChatRequest { reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] plugins: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, } #[derive(Debug, Serialize)] @@ -458,6 +472,14 @@ struct ChatChoice { #[derive(Debug, Deserialize)] struct StreamChunk { choices: Vec, + #[serde(default)] + usage: Option, +} + +#[derive(Debug, Deserialize)] +struct StreamUsage { + #[serde(default)] + cost: Option, } #[derive(Debug, Deserialize)] @@ -525,6 +547,7 @@ mod tests { effort: "high".to_string(), }), plugins: None, + session_id: None, }; let value = serde_json::to_value(request).unwrap(); @@ -537,14 +560,25 @@ mod tests { fn parse_sse_chunk_extracts_content_from_well_formed_delta() { let chunk = r#"data: {"choices":[{"delta":{"content":"hello"}}]}"#; let result = parse_sse_chunk(chunk).unwrap(); - assert_eq!(result, "hello"); + assert_eq!(result.content, "hello"); } #[test] fn parse_sse_chunk_ignores_done_marker() { let chunk = "data: [DONE]"; let result = parse_sse_chunk(chunk).unwrap(); - assert_eq!(result, ""); + assert_eq!(result.content, ""); + } + + #[test] + fn parse_sse_chunk_preserves_usage_cost_from_final_chunk() { + let chunk = r#"data: {"choices":[],"usage":{"cost":0.002341}}"#; + let result = parse_sse_chunk(chunk).unwrap(); + + assert!( + format!("{result:?}").contains("cost_usd: Some(0.002341)"), + "final stream usage must retain OpenRouter's exact response cost" + ); } #[test] diff --git a/frontend/src/__tests__/unit/components/DebateNode.spec.js b/frontend/src/__tests__/unit/components/DebateNode.spec.js index 56b2f63..3ee5cb8 100644 --- a/frontend/src/__tests__/unit/components/DebateNode.spec.js +++ b/frontend/src/__tests__/unit/components/DebateNode.spec.js @@ -24,6 +24,36 @@ function mountNode(debate = {}) { } describe('DebateNode', () => { + it('shows each completed debate response cost in the expanded view', () => { + const wrapper = mount(DebateNode, { + props: { + isExpanded: true, + debate: { + id: 'debate-1', + debate_mode: 'standard', + status: 'completed', + participating_models: ['openai/gpt-4o'], + position: { x: 0, y: 0, width: 320, height: 240 }, + rounds: [{ + round_number: 1, + topic: 'Topic', + created_at: new Date().toISOString(), + responses: [{ + model_id: 'openai/gpt-4o', + model_name: 'GPT-4o', + content: 'Response', + stance: null, + cost_usd: 0.00003 + }] + }] + } + }, + global: { stubs: { GIcon: { template: '' } } } + }) + + expect(wrapper.find('.response-cost').text()).toBe('< $0.0001') + }) + it('removes resize listeners on unmount if a resize is in progress', async () => { const addSpy = vi.spyOn(document, 'addEventListener') const removeSpy = vi.spyOn(document, 'removeEventListener') diff --git a/frontend/src/__tests__/unit/components/LLMNode.spec.js b/frontend/src/__tests__/unit/components/LLMNode.spec.js index 7712359..0c5a4a6 100644 --- a/frontend/src/__tests__/unit/components/LLMNode.spec.js +++ b/frontend/src/__tests__/unit/components/LLMNode.spec.js @@ -24,6 +24,18 @@ function mountNode(response = {}) { } describe('LLMNode', () => { + it('shows a friendly rounded provider-reported cost after completion', () => { + const wrapper = mountNode({ cost_usd: 0.002341 }) + + expect(wrapper.find('.response-cost').text()).toBe('$0.0023') + }) + + it('does not show a cost for legacy responses without provider usage', () => { + const wrapper = mountNode() + + expect(wrapper.find('.response-cost').exists()).toBe(false) + }) + it('removes resize listeners on unmount if a resize is in progress', async () => { const addSpy = vi.spyOn(document, 'addEventListener') const removeSpy = vi.spyOn(document, 'removeEventListener') diff --git a/frontend/src/__tests__/unit/stores/canvas.spec.js b/frontend/src/__tests__/unit/stores/canvas.spec.js index c339a8a..e3f9b83 100644 --- a/frontend/src/__tests__/unit/stores/canvas.spec.js +++ b/frontend/src/__tests__/unit/stores/canvas.spec.js @@ -12,6 +12,27 @@ vi.mock('@tauri-apps/api/event', () => ({ })) describe('Canvas Store', () => { + it('keeps the provider-reported cost when a streamed response completes', async () => { + let streamHandler + listenMock.mockImplementation(async (_eventName, handler) => { + streamHandler = handler + return unlistenMock + }) + vi.spyOn(apiClient.canvas, 'sendPrompt').mockImplementation(async () => { + streamHandler({ payload: { session_id: 'session-1', type: 'tile_created', tile: { + id: 'tile-1', responses: { 'openai/gpt-4': { content: '', status: 'pending' } } + } } }) + streamHandler({ payload: { session_id: 'session-1', type: 'complete', tile_id: 'tile-1', model_id: 'openai/gpt-4', cost_usd: 0.002341 } }) + return 'tile-1' + }) + + const store = useCanvasStore() + store.currentSession = { id: 'session-1', prompt_tiles: [], debates: [] } + await store.sendPrompt('Hello', ['openai/gpt-4']) + + expect(store.currentSession.prompt_tiles[0].responses['openai/gpt-4'].cost_usd).toBe(0.002341) + }) + beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() diff --git a/frontend/src/components/canvas/DebateNode.vue b/frontend/src/components/canvas/DebateNode.vue index b17307e..ad1f084 100644 --- a/frontend/src/components/canvas/DebateNode.vue +++ b/frontend/src/components/canvas/DebateNode.vue @@ -175,8 +175,12 @@ {{ getModelName(modelId) }}
+ {{ formatCost(response.cost_usd) }}
@@ -311,8 +315,7 @@ const lastRoundSummary = computed(() => { if (!firstResponse) return null // Show full content (scrollable in UI) - if (typeof firstResponse !== 'string') return null - return renderMarkdown(firstResponse) + return renderMarkdown(firstResponse.content || firstResponse) }) // Methods @@ -353,7 +356,7 @@ function getRoundResponses(round) { if (round.responses && Array.isArray(round.responses)) { const result = {} for (const resp of round.responses) { - result[resp.model_id] = resp.content + result[resp.model_id] = resp } return result } @@ -372,6 +375,12 @@ function renderContent(content) { return renderMarkdown(content) } +function formatCost(cost) { + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) return null + if (cost < 0.0001) return '< $0.0001' + return `$${cost.toFixed(4)}` +} + function handleMouseDown(e) { // Ignore clicks on interactive elements if (e.target.closest('.node-actions') || @@ -897,6 +906,13 @@ onBeforeUnmount(() => { padding: var(--spacing-sm); } +.response-cost { + display: block; + margin-top: var(--spacing-xs); + color: var(--text-secondary); + font-size: 0.75rem; +} + .response-model { font-size: 0.6875rem; font-weight: 600; diff --git a/frontend/src/components/canvas/LLMNode.vue b/frontend/src/components/canvas/LLMNode.vue index 0d6738f..062f262 100644 --- a/frontend/src/components/canvas/LLMNode.vue +++ b/frontend/src/components/canvas/LLMNode.vue @@ -87,6 +87,12 @@ Waiting... +
+ {{ formattedCost }} +
{ return renderMarkdown(props.response.content) }) +const formattedCost = computed(() => formatCost(props.response.cost_usd)) + +function formatCost(cost) { + if (typeof cost !== 'number' || !Number.isFinite(cost) || cost < 0) return null + if (cost < 0.0001) return '< $0.0001' + return `$${cost.toFixed(4)}` +} + // Watch for branch input focus watch(showBranch, async (isShowing) => { if (isShowing) { @@ -883,6 +897,12 @@ onBeforeUnmount(() => { color: var(--text-primary); } +.response-cost { + padding: 0 var(--spacing-sm) var(--spacing-sm); + color: var(--text-secondary); + font-size: 0.75rem; +} + .node-content :deep(p:last-child) { margin-bottom: 0; } diff --git a/frontend/src/stores/canvas.js b/frontend/src/stores/canvas.js index 30a4e96..b4e02a9 100644 --- a/frontend/src/stores/canvas.js +++ b/frontend/src/stores/canvas.js @@ -449,7 +449,7 @@ export const useCanvasStore = defineStore('canvas', () => { updateTileResponseLocal(data.tile_id, data.model_id, modelContent[data.model_id], 'streaming') } const applyComplete = (data) => { - updateTileResponseLocal(data.tile_id, data.model_id, modelContent[data.model_id], 'completed') + updateTileResponseLocal(data.tile_id, data.model_id, modelContent[data.model_id], 'completed', null, data.cost_usd) tracker.clear(`${ownTileId}:${data.model_id}`) } const applyError = (data) => { @@ -537,7 +537,7 @@ export const useCanvasStore = defineStore('canvas', () => { }) } - function updateTileResponseLocal(tileId, modelId, content, status, errorMessage = null) { + function updateTileResponseLocal(tileId, modelId, content, status, errorMessage = null, costUsd) { if (!currentSession.value) return const tile = currentSession.value.prompt_tiles.find(t => t.id === tileId) @@ -550,6 +550,7 @@ export const useCanvasStore = defineStore('canvas', () => { tile.responses[modelId].error_message = status === 'error' ? (errorMessage || content || 'Error occurred') : null + if (costUsd !== undefined) tile.responses[modelId].cost_usd = costUsd } } @@ -1010,7 +1011,7 @@ export const useCanvasStore = defineStore('canvas', () => { }, complete: (data) => { if (data.tile_id !== tileId || !newModelIds.includes(data.model_id)) return - updateTileResponseLocal(tileId, data.model_id, modelContent[data.model_id], 'completed') + updateTileResponseLocal(tileId, data.model_id, modelContent[data.model_id], 'completed', null, data.cost_usd) tracker.clear(`${tileId}:${data.model_id}`) }, error: (data) => { @@ -1072,7 +1073,7 @@ export const useCanvasStore = defineStore('canvas', () => { }, complete: (data) => { if (data.tile_id !== tileId || data.model_id !== modelId) return - updateTileResponseLocal(tileId, modelId, content, 'completed') + updateTileResponseLocal(tileId, modelId, content, 'completed', null, data.cost_usd) tracker.clear(streamingKey) }, error: (data) => {