Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
71 changes: 38 additions & 33 deletions frontend/src-tauri/src/commands/canvas/debate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ 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;
use serde_json::json;
use std::time::Duration;
use tauri::State;

fn no_cost_stream<S>(stream: S) -> std::pin::Pin<Box<dyn futures::Stream<Item = anyhow::Result<StreamUpdate>> + Send>>
where
S: futures::Stream<Item = anyhow::Result<String>> + 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(
Expand Down Expand Up @@ -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<Item = anyhow::Result<String>>
+ Send,
>,
>
})
result.map(no_cost_stream)
}
ModelProviderRoute::OpenRouter => {
let openrouter = openrouter_arc.read().await;
Expand All @@ -217,31 +216,29 @@ 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<Item = anyhow::Result<String>>
+ Send,
>,
>
})
result.map(|stream| Box::pin(stream) as std::pin::Pin<Box<dyn futures::Stream<Item = anyhow::Result<StreamUpdate>> + Send>>)
}
};

match stream_result {
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(
Expand Down Expand Up @@ -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
Expand All @@ -291,6 +289,7 @@ pub async fn start_debate(
model_name,
content: full_content,
stance: None,
cost_usd: None,
};
}
}
Expand All @@ -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,
},
);

Expand All @@ -311,6 +311,7 @@ pub async fn start_debate(
model_name,
content: full_content,
stance: None,
cost_usd,
}
}
Err(e) => {
Expand All @@ -331,13 +332,15 @@ pub async fn start_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(),
stance: None,
cost_usd: None,
}
}
}
Expand Down Expand Up @@ -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<dyn futures::Stream<Item = anyhow::Result<String>> + Send>,
>
})
result.map(no_cost_stream)
}
ModelProviderRoute::OpenRouter => {
let openrouter = openrouter_arc.read().await;
Expand All @@ -556,27 +554,28 @@ 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<dyn futures::Stream<Item = anyhow::Result<String>> + Send>,
>
})
result.map(|stream| Box::pin(stream) as std::pin::Pin<Box<dyn futures::Stream<Item = anyhow::Result<StreamUpdate>> + Send>>)
}
};

match stream_result {
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(
Expand Down Expand Up @@ -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
Expand All @@ -626,6 +626,7 @@ pub async fn continue_debate(
model_name,
content: full_content,
stance: None,
cost_usd: None,
};
}
}
Expand All @@ -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,
},
);

Expand All @@ -646,6 +648,7 @@ pub async fn continue_debate(
model_name,
content: full_content,
stance: None,
cost_usd,
}
}
Err(e) => {
Expand All @@ -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,
}
}
Expand Down
Loading
Loading