Skip to content
Open
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
23 changes: 20 additions & 3 deletions windows/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod hooks;
mod integrations;
mod island;
mod log;
mod openai_compat;
mod pipe;
mod platform;
mod secrets;
Expand Down Expand Up @@ -238,16 +239,30 @@ fn approval_decline(app: AppHandle, request_id: String) {
async fn chat_send(
shared: State<'_, Shared>,
chat: State<'_, Chat>,
oai: State<'_, openai_compat::OaiChat>,
query: String,
context: Option<ChatContext>,
) -> Result<ChatReply, String> {
let model = shared.settings.lock().unwrap().model.clone();
claude::send(&chat, &model, query, context).await
let (provider, model, or_model) = {
let s = shared.settings.lock().unwrap();
(s.chat_provider.clone(), s.model.clone(), s.openrouter_model.clone())
};
match provider.as_str() {
"openrouter" => openai_compat::send(&oai, &or_model, query, context).await,
_ => claude::send(&chat, &model, query, context).await,
}
}

#[tauri::command]
fn chat_reset(chat: State<Chat>) {
fn chat_reset(chat: State<Chat>, oai: State<openai_compat::OaiChat>) {
chat.reset();
oai.reset();
}

/// OpenRouter's public model list (no key needed), free models first.
#[tauri::command]
async fn openrouter_models() -> Result<Vec<openai_compat::ModelInfo>, String> {
openai_compat::list_models().await
}

/// Copies a dropped file into the inbox and reports its name back.
Expand Down Expand Up @@ -374,6 +389,7 @@ pub fn run() {
})
.manage(Pending::default())
.manage(Chat::default())
.manage(openai_compat::OaiChat::default())
.invoke_handler(tauri::generate_handler![
boot,
save_settings,
Expand All @@ -393,6 +409,7 @@ pub fn run() {
log_line,
chat_send,
chat_reset,
openrouter_models,
ingest_file,
secret_present,
secret_set,
Expand Down
218 changes: 218 additions & 0 deletions windows/src-tauri/src/openai_compat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
// OpenAI-compatible chat client, used for OpenRouter.
//
// Same contract as claude.rs: one multi-turn chat, the API key never leaves the
// keychain, file bytes never cross the IPC boundary. Chat only: no tools are
// sent, so the model can answer but can never act on the machine.

use std::sync::Mutex;

use serde::Serialize;
use serde_json::{json, Value};

use crate::claude::{ChatContext, ChatReply};
use crate::secrets;

pub const OPENROUTER_BASE: &str = "https://openrouter.ai/api/v1";
pub const OPENROUTER_KEY: &str = "openrouter-api-key";
const MAX_TOKENS: u32 = 4096;
/// Text and code files are inlined; anything larger is skipped.
const MAX_INLINE_TEXT: u64 = 200_000;
/// Images are sent inline as data URLs; past this size they are skipped.
const MAX_IMAGE: u64 = 5_000_000;

const SYSTEM_PROMPT: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \
You can help with questions, coding, writing, explanations and planning. You have no tools and no internet access: \
if something needs current information, say so. Respond in the user's language. \
No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks.";

#[derive(Default)]
pub struct OaiChat {
/// User and assistant turns, OpenAI message format, without the system prompt.
messages: Mutex<Vec<Value>>,
}

impl OaiChat {
pub fn reset(&self) {
self.messages.lock().unwrap().clear();
}
}

/// One chat turn against OpenRouter.
pub async fn send(
chat: &OaiChat,
model: &str,
query: String,
context: Option<ChatContext>,
) -> Result<ChatReply, String> {
let key = secrets::get(OPENROUTER_KEY)
.ok_or_else(|| "OpenRouter API key missing. Open settings.".to_string())?;
if model.trim().is_empty() {
return Err("Pick an OpenRouter model in settings.".into());
}

let first = chat.messages.lock().unwrap().is_empty();
let mut content: Vec<Value> = Vec::new();
// File / window context rides along with the first message only, as in claude.rs.
if first {
match &context {
Some(ChatContext::File { name, path }) => {
if let Some(part) = file_part(path) {
content.push(part);
}
content.push(json!({ "type": "text", "text": format!("File: {name}") }));
}
Some(ChatContext::Window { app_name, title, url }) => {
let mut text = format!("Context — App: {app_name}, Window: {title}");
if let Some(url) = url {
text.push_str(&format!(", URL: {url}"));
}
content.push(json!({ "type": "text", "text": text }));
}
None => {}
}
}
content.push(json!({ "type": "text", "text": query }));
let user = json!({ "role": "user", "content": content });

let mut messages = vec![json!({ "role": "system", "content": SYSTEM_PROMPT })];
messages.extend(chat.messages.lock().unwrap().iter().cloned());
messages.push(user.clone());

let body = json!({
"model": model,
"max_tokens": MAX_TOKENS,
"messages": messages,
});

let response = call(&key, &body).await?;

let text = response
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_string();
if text.is_empty() {
// OpenRouter can return 200 with an error object (provider failure).
if let Some(msg) = response.pointer("/error/message").and_then(Value::as_str) {
return Err(format!("OpenRouter: {msg}"));
}
return Err("No response text.".into());
}

// Only a successful turn enters the history, so it always matches what the model saw.
let mut history = chat.messages.lock().unwrap();
history.push(user);
history.push(json!({ "role": "assistant", "content": text }));
Ok(ChatReply { text })
}

fn client(timeout_secs: u64) -> Result<reqwest::Client, String> {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.build()
.map_err(|e| e.to_string())
}

async fn call(key: &str, body: &Value) -> Result<Value, String> {
let response = client(120)?
.post(format!("{OPENROUTER_BASE}/chat/completions"))
.bearer_auth(key)
// Optional attribution headers documented by OpenRouter.
.header("X-Title", "Coucou")
.header("HTTP-Referer", "https://github.com/Louis-CFM/coucou")
.json(body)
.send()
.await
.map_err(|e| format!("Network error: {e}"))?;

let status = response.status();
let text = response.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let detail = serde_json::from_str::<Value>(&text)
.ok()
.and_then(|v| v.pointer("/error/message").and_then(Value::as_str).map(str::to_string))
.unwrap_or_else(|| text.chars().take(200).collect());
return Err(match status.as_u16() {
401 => "OpenRouter rejected the API key (401). Check it in settings.".into(),
402 => format!("OpenRouter: not enough credits (402). {detail}"),
429 => format!("OpenRouter rate limit reached (429): {detail}. Free models have daily limits; try later or pick another model."),
_ => format!("OpenRouter {status}: {detail}"),
});
}
serde_json::from_str(&text).map_err(|e| format!("Bad API response: {e}"))
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub free: bool,
}

/// The public OpenRouter catalogue, text-output models only, free ones first.
/// No key needed for the list itself.
pub async fn list_models() -> Result<Vec<ModelInfo>, String> {
let response = client(30)?
.get(format!("{OPENROUTER_BASE}/models"))
.send()
.await
.map_err(|e| format!("Network error: {e}"))?;
if !response.status().is_success() {
return Err(format!("OpenRouter models: HTTP {}", response.status()));
}
let json: Value = response.json().await.map_err(|e| e.to_string())?;
let mut models: Vec<ModelInfo> = json
.get("data")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|m| {
m.pointer("/architecture/output_modalities")
.and_then(Value::as_array)
.map(|mods| mods.iter().any(|x| x.as_str() == Some("text")))
.unwrap_or(true)
})
.filter_map(|m| {
let id = m.get("id")?.as_str()?.to_string();
let name = m.get("name").and_then(Value::as_str).unwrap_or(&id).to_string();
let zero = |p: &str| m.pointer(p).and_then(Value::as_str).map(|v| v.parse::<f64>().ok() == Some(0.0)).unwrap_or(false);
let free = id.ends_with(":free") || (zero("/pricing/prompt") && zero("/pricing/completion"));
Some(ModelInfo { id, name, free })
})
.collect();
models.sort_by(|a, b| b.free.cmp(&a.free).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(models)
}

/// Images → image_url data URL, text/code → inline text. PDFs and other binary
/// files are not sent (OpenAI-compatible APIs have no portable document part).
fn file_part(path: &str) -> Option<Value> {
let ext = std::path::Path::new(path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let len = std::fs::metadata(path).ok()?.len();
let image = match ext.as_str() {
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"gif" => Some("image/gif"),
"webp" => Some("image/webp"),
_ => None,
};
if let Some(media) = image {
if len > MAX_IMAGE {
return None;
}
let bytes = std::fs::read(path).ok()?;
let url = format!("data:{media};base64,{}", crate::claude::base64_for(&bytes));
return Some(json!({ "type": "image_url", "image_url": { "url": url } }));
}
if ext == "pdf" || len > MAX_INLINE_TEXT {
return None;
}
let text = std::fs::read_to_string(path).ok()?;
Some(json!({ "type": "text", "text": format!("File contents:\n{text}") }))
}
1 change: 1 addition & 0 deletions windows/src-tauri/src/secrets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const SERVICE: &str = "fr.louisraille.coucou";
/// Every key Coucou may store. Anything outside this list is refused.
pub const KNOWN_KEYS: &[&str] = &[
"anthropic-api-key",
"openrouter-api-key",
"n8n-url",
"n8n-api-key",
"vercel-token",
Expand Down
12 changes: 12 additions & 0 deletions windows/src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ pub struct Settings {
/// Defaulted explicitly so a settings.json written by an older build still loads.
#[serde(default = "default_model")]
pub model: String,
/// Chat backend: "anthropic" (Claude API) or "openrouter".
#[serde(default = "default_chat_provider")]
pub chat_provider: String,
/// OpenRouter model id, e.g. "qwen/qwen3.8-27b:free".
#[serde(default)]
pub openrouter_model: String,
}

fn default_chat_provider() -> String {
"anthropic".into()
}

fn default_model() -> String {
Expand All @@ -43,6 +53,8 @@ impl Default for Settings {
autostart: false,
hooks_installed: false,
model: default_model(),
chat_provider: default_chat_provider(),
openrouter_model: String::new(),
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions windows/src/core/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ export const Bridge = {
chatSend: (query: string, context: ChatContext | null) =>
callOrThrow<{ text: string }>("chat_send", { query, context }),
chatReset: () => call<void>("chat_reset"),
/** OpenRouter's public catalogue, free models first. */
openrouterModels: () =>
callOrThrow<{ id: string; name: string; free: boolean }[]>("openrouter_models"),
/** Copies a dropped file into the inbox. */
ingestFile: (path: string) => callOrThrow<DroppedFile>("ingest_file", { path }),
/** Only ever tells you whether a key exists — never its value. */
Expand Down
6 changes: 6 additions & 0 deletions windows/src/core/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ export interface Settings {
hooksInstalled: boolean;
/** Claude model used by the chat. */
model: string;
/** Chat backend. */
chatProvider: "anthropic" | "openrouter";
/** OpenRouter model id used when chatProvider is "openrouter". */
openrouterModel: string;
}

export const DEFAULT_SETTINGS: Settings = {
Expand All @@ -106,6 +110,8 @@ export const DEFAULT_SETTINGS: Settings = {
autostart: false,
hooksInstalled: false,
model: "claude-opus-5",
chatProvider: "anthropic",
openrouterModel: "",
};

type Listener = () => void;
Expand Down
Loading