diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..7edf70580 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ mod hooks; mod integrations; mod island; mod log; +mod openai_compat; mod pipe; mod platform; mod secrets; @@ -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, ) -> Result { - 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) { +fn chat_reset(chat: State, oai: State) { chat.reset(); + oai.reset(); +} + +/// OpenRouter's public model list (no key needed), free models first. +#[tauri::command] +async fn openrouter_models() -> Result, String> { + openai_compat::list_models().await } /// Copies a dropped file into the inbox and reports its name back. @@ -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, @@ -393,6 +409,7 @@ pub fn run() { log_line, chat_send, chat_reset, + openrouter_models, ingest_file, secret_present, secret_set, diff --git a/windows/src-tauri/src/openai_compat.rs b/windows/src-tauri/src/openai_compat.rs new file mode 100644 index 000000000..7d199c8b9 --- /dev/null +++ b/windows/src-tauri/src/openai_compat.rs @@ -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>, +} + +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, +) -> Result { + 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 = 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::builder() + .timeout(std::time::Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| e.to_string()) +} + +async fn call(key: &str, body: &Value) -> Result { + 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::(&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, 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 = 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::().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 { + 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}") })) +} diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..cca4975ab 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -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", diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..1661d7bd3 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -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 { @@ -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(), } } } diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..69b1c2b42 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -85,6 +85,9 @@ export const Bridge = { chatSend: (query: string, context: ChatContext | null) => callOrThrow<{ text: string }>("chat_send", { query, context }), chatReset: () => call("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("ingest_file", { path }), /** Only ever tells you whether a key exists — never its value. */ diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..d047491b4 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -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 = { @@ -106,6 +110,8 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "anthropic", + openrouterModel: "", }; type Listener = () => void; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..72404155c 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -181,7 +181,7 @@ const MODELS: [string, string][] = [ function apiSection(hasKey: boolean): HTMLElement { const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — needed only when the chat uses the Claude API." }); const field = h("input", { type: "password", @@ -200,7 +200,7 @@ function apiSection(hasKey: boolean): HTMLElement { dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present ? "Key saved in the Windows Credential Manager." - : "No key yet — the chat needs one."; + : "No key yet — needed only when the chat uses the Claude API."; field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; clearBtn.style.display = present ? "" : "none"; } @@ -254,6 +254,123 @@ function apiSection(hasKey: boolean): HTMLElement { ); } +// ── Chat provider / OpenRouter section ──────────────────────────────────────── + +const OR_KEY = "openrouter-api-key"; + +function openrouterSection(hasKey: boolean): HTMLElement { + const dot = statusDot(hasKey); + const state = h("span", { class: "hint" }); + + const provider = h("select", {}) as HTMLSelectElement; + provider.append( + h("option", { value: "anthropic", text: "Claude API (Anthropic)" }), + h("option", { value: "openrouter", text: "OpenRouter" }), + ); + provider.value = settings.chatProvider; + provider.addEventListener("change", () => { + settings.chatProvider = provider.value as Settings["chatProvider"]; + void save(); + }); + + const field = h("input", { + type: "password", + style: "flex:1 1 auto;min-width:0", + autocomplete: "off", + spellcheck: "false", + }) as HTMLInputElement; + const saveBtn = h("button", { class: "primary", text: "Save key" }); + const clearBtn = h("button", { class: "danger", text: "Remove" }); + const feedback = h("div", {}); + + async function refresh() { + const present = (await Bridge.secretPresent(OR_KEY)) ?? false; + dot.style.background = present ? "#22c55e" : "#f4505e"; + state.textContent = present + ? "Key saved in the system keychain." + : "No key yet — create one at openrouter.ai/keys."; + field.placeholder = present ? "•••••••••••• (stored)" : "sk-or-..."; + clearBtn.style.display = present ? "" : "none"; + } + void refresh(); + + saveBtn.addEventListener("click", async () => { + const value = field.value.trim(); + if (!value) return; + clear(feedback); + try { + await Bridge.secretSet(OR_KEY, value); + field.value = ""; + feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); + await refresh(); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); + } + }); + clearBtn.addEventListener("click", async () => { + clear(feedback); + try { + await Bridge.secretClear(OR_KEY); + feedback.append(h("div", { class: "notice ok", text: "Key removed." })); + await refresh(); + } catch (err) { + feedback.append(h("div", { class: "notice err", text: `Could not remove: ${String(err)}` })); + } + }); + + // Model list: fetched live from OpenRouter, free models first. + const model = h("select", { style: "flex:1 1 auto;min-width:0;max-width:340px" }) as HTMLSelectElement; + let all: { id: string; name: string; free: boolean }[] = []; + let freeOnly = true; + function fillModels() { + clear(model); + const list = all.filter((m) => !freeOnly || m.free); + if (settings.openrouterModel && !list.some((m) => m.id === settings.openrouterModel)) { + model.append(h("option", { value: settings.openrouterModel, text: settings.openrouterModel })); + } + if (!settings.openrouterModel) model.append(h("option", { value: "", text: "Choose a model…" })); + for (const m of list) { + // OpenRouter already puts "(free)" in most free model names. + const label = m.free && !/\(free\)/i.test(m.name) ? `${m.name} (free)` : m.name; + model.append(h("option", { value: m.id, text: label })); + } + model.value = settings.openrouterModel; + } + model.addEventListener("change", () => { + settings.openrouterModel = model.value; + void save(); + }); + model.append(h("option", { value: settings.openrouterModel, text: settings.openrouterModel || "Loading models…" })); + void Bridge.openrouterModels() + .then((list) => { + all = list ?? []; + fillModels(); + }) + .catch((err) => { + feedback.append(h("div", { class: "notice err", text: `Could not load models: ${String(err)}` })); + }); + + return h( + "section", + {}, + h("h2", {}, dot, h("span", { text: "Chat provider" })), + h("div", { class: "row" }, h("label", { text: "Chat uses" }), provider), + h("div", { class: "row" }, h("label", { text: "OpenRouter" }), state), + h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), + h("div", { class: "row" }, + h("label", { text: "Model" }), + model, + h("label", { text: "Free only" }), + toggle(freeOnly, (v) => { freeOnly = v; fillModels(); }), + ), + h("div", { + class: "hint", + text: "Chat only: no tools are sent, the model cannot act on this computer. Free models have daily limits and their providers may log prompts.", + }), + feedback, + ); +} + // ── Integrations section ────────────────────────────────────────────────────── interface IntegrationDef { @@ -443,6 +560,7 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), claudeSection(status), apiSection(hasKey), + openrouterSection((await Bridge.secretPresent(OR_KEY)) ?? false), integrationsSection(present), generalSection(), h("div", {