From 877709a7c4cacdf22ed45403d35c55e6c9e540f4 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 17:27:21 +0200 Subject: [PATCH 01/10] Add Gemini Qwen and OpenRouter sessions --- README.md | 12 +- src-tauri/src/lib.rs | 260 ++++++++++++++++++++++++++++--------- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 8 +- src/brand-icons.tsx | 44 ++++++- src/inspector.tsx | 11 +- src/new-session-dialog.tsx | 34 ++--- src/provider-auth.tsx | 30 +++++ src/types.ts | 20 ++- 9 files changed, 315 insertions(+), 106 deletions(-) diff --git a/README.md b/README.md index 606c4cd..9b07b85 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Ultralytics Forums](https://img.shields.io/discourse/users?server=https%3A%2F%2Fcommunity.ultralytics.com&logo=discourse&label=Forums&color=blue)](https://community.ultralytics.com) [![Ultralytics Reddit](https://img.shields.io/reddit/subreddit-subscribers/ultralytics?style=flat&logo=reddit&logoColor=white&label=Reddit&color=blue)](https://reddit.com/r/ultralytics) -Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI or [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), [Kimi Code](https://www.kimi.com/code), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service. +Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI, [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), or [OpenRouter](https://openrouter.ai/docs/guides/guides/codex), [Gemini CLI](https://google-gemini.github.io/gemini-cli/), [Kimi Code](https://www.kimi.com/code), [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.

@@ -21,8 +21,8 @@ Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/e ## ✨ Features -- Run Claude Code, Codex, Kimi Code, and shell sessions side by side -- Run Codex against DeepSeek when your Codex configuration provides that model +- Run Claude Code, Codex, Gemini CLI, Kimi Code, Qwen Code, and shell sessions side by side +- Run Codex against DeepSeek or OpenRouter without changing your default Codex provider - Resume session tabs automatically after closing Lite or restarting your computer - Authenticate once with each provider and reuse its existing local credentials - Or save an API key per provider in Lite and skip the sign-in flows entirely @@ -73,11 +73,13 @@ Install the provider CLIs you want to use: - [Claude Code](https://code.claude.com/docs/en/setup) - [Codex](https://developers.openai.com/codex/cli) +- [Gemini CLI](https://google-gemini.github.io/gemini-cli/) - [Kimi Code](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html) — Windows also needs Git for Windows +- [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/) Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog tells you when a CLI is missing and links to its setup guide. -If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `MOONSHOT_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data. +If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `MOONSHOT_API_KEY`, or `DASHSCOPE_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data.
Saving provider API keys in Lite @@ -98,6 +100,8 @@ experimental_bearer_token = "" Leave `preferred_auth_method` and `forced_login_method` out. Codex applies those globally, and setting them signs you out of ChatGPT the next time it runs. Lite selects the DeepSeek provider per launch, so your default Codex provider and existing OpenAI sessions are untouched, and it never reads or stores the DeepSeek key. +**Codex · OpenRouter** works the same way: save an OpenRouter key in Lite or configure an `openrouter` Codex provider, and Lite launches OpenRouter's current OpenAI model route without changing your default Codex setup. + Choose a project folder, create a session, and work. Lite saves only the local metadata needed to restore tabs; provider conversation history remains owned by the provider CLI. ## 🛠️ Development diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8910724..8b311d3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -29,9 +29,47 @@ const MAX_GIT_DIFF_BYTES: u64 = 1_000_000; const CODEX_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); // Requests stay bounded so an app server that never answers surfaces an error instead of a stuck tab. const CODEX_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); -const DEEPSEEK_PROFILE: &str = "deepseek"; const DEEPSEEK_MODEL: &str = "deepseek-v4-flash"; -const SUPPORTED_KEYS: [&str; 4] = ["claude", "codex", "deepseek", "kimi"]; +const QWEN_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; +const QWEN_MODEL: &str = "qwen3.7-plus"; +const SUPPORTED_KEYS: [&str; 7] = [ + "claude", + "codex", + "deepseek", + "openrouter", + "gemini", + "kimi", + "qwen", +]; + +#[derive(Clone, Copy)] +struct CodexProvider { + id: &'static str, + name: &'static str, + base_url: &'static str, + env_key: &'static str, + model: &'static str, + setup_url: &'static str, +} + +const CODEX_PROVIDERS: [CodexProvider; 2] = [ + CodexProvider { + id: "deepseek", + name: "DeepSeek", + base_url: "https://api.deepseek.com/", + env_key: "DEEPSEEK_API_KEY", + model: DEEPSEEK_MODEL, + setup_url: "https://api-docs.deepseek.com/quick_start/agent_integrations/codex", + }, + CodexProvider { + id: "openrouter", + name: "OpenRouter", + base_url: "https://openrouter.ai/api/v1", + env_key: "OPENROUTER_API_KEY", + model: "~openai/gpt-latest", + setup_url: "https://openrouter.ai/docs/guides/guides/codex", + }, +]; struct PtySession { child: Box, @@ -220,12 +258,14 @@ fn is_sensitive_component(component: &std::ffi::OsStr) -> bool { ".codex", ".config", ".docker", + ".gemini", ".git-credentials", ".gnupg", ".kimi-code", ".netrc", ".npmrc", ".pypirc", + ".qwen", ".ssh", "id_ed25519", "id_rsa", @@ -1459,12 +1499,25 @@ fn api_keys_path(app: &AppHandle) -> Result { .join("api-keys.json")) } +fn codex_provider(id: Option<&str>) -> Option { + CODEX_PROVIDERS + .iter() + .copied() + .find(|provider| Some(provider.id) == id) +} + fn api_key_env(agent: &str, provider: Option<&str>) -> Option<(&'static str, &'static str)> { + if agent == "codex" + && let Some(provider) = codex_provider(provider) + { + return Some((provider.id, provider.env_key)); + } match (agent, provider) { ("claude", _) => Some(("claude", "ANTHROPIC_API_KEY")), - ("codex", Some("deepseek")) => Some(("deepseek", "DEEPSEEK_API_KEY")), ("codex", _) => Some(("codex", "OPENAI_API_KEY")), + ("gemini", _) => Some(("gemini", "GEMINI_API_KEY")), ("kimi", _) => Some(("kimi", "MOONSHOT_API_KEY")), + ("qwen", _) => Some(("qwen", "DASHSCOPE_API_KEY")), _ => None, } } @@ -1636,13 +1689,27 @@ fn cli_auth(app: &AppHandle, name: &str) -> Option { method: CliAuthMethod::Provider, key_hint: None, }), - "deepseek" => { - (deepseek_profile_exists(app) || codex_declares_deepseek(app)).then_some(CliAuth { - method: CliAuthMethod::ApiKey, - key_hint: None, - }) + name if CODEX_PROVIDERS.iter().any(|provider| provider.id == name) => { + let provider = codex_provider(Some(name))?; + (codex_profile_exists(app, provider.id) || codex_declares_provider(app, provider.id)) + .then_some(CliAuth { + method: CliAuthMethod::ApiKey, + key_hint: None, + }) } + "gemini" => gemini_home(app) + .is_ok_and(|home| home.join("oauth_creds.json").is_file()) + .then_some(CliAuth { + method: CliAuthMethod::Provider, + key_hint: None, + }), "kimi" => kimi_auth(app), + "qwen" => qwen_home(app) + .is_ok_and(|home| home.join("settings.json").is_file()) + .then_some(CliAuth { + method: CliAuthMethod::Provider, + key_hint: None, + }), _ => None, } } @@ -1724,12 +1791,30 @@ fn executable_exists(name: &str) -> bool { resolve_executable(name).is_some() } +fn agent_executable(agent: &str) -> Option<&'static str> { + match agent { + "claude" => Some("claude"), + "codex" => Some("codex"), + "gemini" => Some("gemini"), + "kimi" => Some("kimi"), + "qwen" => Some("qwen"), + _ => None, + } +} + +fn agent_builder(agent: &str) -> Result { + let executable = agent_executable(agent).ok_or("Unknown session type")?; + let path = resolve_executable(executable) + .ok_or_else(|| format!("Could not find {executable} in your PATH"))?; + Ok(CommandBuilder::new(path)) +} + fn codex_home(app: &AppHandle) -> Result { provider_home(app, "CODEX_HOME", ".codex") } -// Looks only for the section header. The DeepSeek credential lives in that file and is never read. -fn codex_declares_deepseek(app: &AppHandle) -> bool { +// Looks only for the section header. Provider credentials in that file are never read. +fn codex_declares_provider(app: &AppHandle, provider: &str) -> bool { let Ok(home) = codex_home(app) else { return false; }; @@ -1739,7 +1824,7 @@ fn codex_declares_deepseek(app: &AppHandle) -> bool { BufReader::new(file) .lines() .map_while(Result::ok) - .any(|line| line.trim() == "[model_providers.deepseek]") + .any(|line| line.trim() == format!("[model_providers.{provider}]")) } // A catalog is a replacement rather than a merge, so one the user configured is left to win. @@ -1756,17 +1841,22 @@ fn codex_declares_catalog(app: &AppHandle) -> bool { .any(|line| line.trim_start().starts_with("model_catalog_json")) } -fn deepseek_profile_exists(app: &AppHandle) -> bool { - codex_home(app).is_ok_and(|home| { - home.join(format!("{DEEPSEEK_PROFILE}.config.toml")) - .is_file() - }) +fn codex_profile_exists(app: &AppHandle, provider: &str) -> bool { + codex_home(app).is_ok_and(|home| home.join(format!("{provider}.config.toml")).is_file()) +} + +fn gemini_home(app: &AppHandle) -> Result { + provider_home(app, "GEMINI_CLI_HOME", ".gemini") } fn kimi_home(app: &AppHandle) -> Result { provider_home(app, "KIMI_CODE_HOME", ".kimi-code") } +fn qwen_home(app: &AppHandle) -> Result { + provider_home(app, "QWEN_HOME", ".qwen") +} + // Kimi groups sessions under an opaque per-directory key that its workspace index maps back to a path. fn kimi_workspace_key(app: &AppHandle, cwd: &Path) -> Option { let bytes = fs::read(kimi_home(app).ok()?.join("workspaces.json")).ok()?; @@ -1841,7 +1931,7 @@ fn agent_command( ) -> Result { let mut command = match agent { "claude" => { - let mut command = CommandBuilder::new("claude"); + let mut command = agent_builder(agent)?; if resume { command.args(["--resume", session_id]); } else { @@ -1852,31 +1942,38 @@ fn agent_command( command } "codex" => { - let mut command = CommandBuilder::new("codex"); - // DeepSeek is selected per launch so the default Codex provider stays whatever the user set. - if provider == Some("deepseek") { - let key = saved_api_key(app, agent, provider).is_some(); - if !key && deepseek_profile_exists(app) { + let mut command = agent_builder(agent)?; + // Providers are selected per launch so the user's default Codex provider stays untouched. + if let Some(provider) = codex_provider(provider) { + let key = saved_api_key(app, agent, Some(provider.id)).is_some(); + if !key && codex_profile_exists(app, provider.id) { // A profile the user wrote owns the whole model configuration, catalog included. - command.args(["--profile", DEEPSEEK_PROFILE]); + command.args(["--profile", provider.id]); } else { if key { // A key held by Lite defines the provider inline and is read from the // environment, so no Codex configuration file has to exist or be written. for override_value in [ - "model_providers.deepseek.name=\"deepseek\"", - "model_providers.deepseek.base_url=\"https://api.deepseek.com/\"", - "model_providers.deepseek.wire_api=\"responses\"", - "model_providers.deepseek.env_key=\"DEEPSEEK_API_KEY\"", + format!("model_providers.{}.name=\"{}\"", provider.id, provider.name), + format!( + "model_providers.{}.base_url=\"{}\"", + provider.id, provider.base_url + ), + format!("model_providers.{}.wire_api=\"responses\"", provider.id), + format!( + "model_providers.{}.env_key=\"{}\"", + provider.id, provider.env_key + ), ] { - command.args(["-c", override_value]); + command.args(["-c", &override_value]); } } - command.args(["-c", "model_provider=\"deepseek\""]); - command.args(["-c", &format!("model=\"{DEEPSEEK_MODEL}\"")]); - if let Some(catalog) = (!codex_declares_catalog(app)) - .then(|| deepseek_catalog(app)) - .flatten() + command.args(["-c", &format!("model_provider=\"{}\"", provider.id)]); + command.args(["-c", &format!("model=\"{}\"", provider.model)]); + if let Some(catalog) = (provider.id == "deepseek" + && !codex_declares_catalog(app)) + .then(|| deepseek_catalog(app)) + .flatten() { // A Windows path is full of backslashes, which TOML reads as escapes. let catalog = path_text(&catalog) @@ -1891,16 +1988,39 @@ fn agent_command( } command } + "gemini" => { + let mut command = agent_builder(agent)?; + command.args(if resume { + ["--resume", session_id] + } else { + ["--session-id", session_id] + }); + command + } "kimi" => { // Only an exact id resumes. `--continue` would reopen whatever ran last in the directory, // which two tabs there would both land on, and it creates no entry for discovery to record, // so the tab could never learn which session it is showing. - let mut command = CommandBuilder::new("kimi"); + let mut command = agent_builder(agent)?; if let Some(provider_session_id) = provider_session_id { command.args(["--session", provider_session_id]); } command } + "qwen" => { + let mut command = agent_builder(agent)?; + command.args(if resume { + ["--resume", session_id] + } else { + ["--session-id", session_id] + }); + if saved_api_key(app, agent, provider).is_some() { + command.args(["--auth-type", "openai"]); + command.args(["--openai-base-url", QWEN_BASE_URL]); + command.args(["--model", QWEN_MODEL]); + } + command + } "shell" => { let mut command = CommandBuilder::new_default_prog(); #[cfg(target_os = "macos")] @@ -1917,6 +2037,9 @@ fn agent_command( && let Some(key) = saved_api_key(app, agent, provider) { command.env(variable, key); + if agent == "gemini" { + command.env("GEMINI_DEFAULT_AUTH_TYPE", "gemini-api-key"); + } } Ok(command) } @@ -1925,20 +2048,22 @@ fn agent_command( fn login_command(agent: &str) -> Result { let mut command = match agent { "claude" => { - let mut command = CommandBuilder::new("claude"); + let mut command = agent_builder(agent)?; command.args(["auth", "login"]); command } "codex" => { - let mut command = CommandBuilder::new("codex"); + let mut command = agent_builder(agent)?; command.arg("login"); command } + "gemini" => agent_builder(agent)?, "kimi" => { - let mut command = CommandBuilder::new("kimi"); + let mut command = agent_builder(agent)?; command.arg("login"); command } + "qwen" => agent_builder(agent)?, _ => return Err("This provider signs in with an API key".into()), }; if let Some(path) = user_path() { @@ -1960,23 +2085,22 @@ async fn agent_availability( agent: String, provider: Option, ) -> Result { - let (executable, missing) = match agent.as_str() { - "claude" => ( - "claude", - "Install the Claude Code CLI, then sign in with `claude`.", - ), - "codex" => ( - "codex", - "Install the Codex CLI, then sign in with `codex login`.", - ), - "kimi" => ( - "kimi", + let missing = match agent.as_str() { + "claude" => "Install the Claude Code CLI, then sign in with `claude`.", + "codex" => "Install the Codex CLI, then sign in with `codex login`.", + "gemini" => { + "Install Gemini CLI with `npm install -g @google/gemini-cli`, then run `gemini`." + } + "kimi" => { if cfg!(windows) { "Install Git for Windows, then run `irm https://code.kimi.com/kimi-code/install.ps1 | iex` in PowerShell." } else { "Install Kimi Code with `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`." - }, - ), + } + } + "qwen" => { + "Install Qwen Code with `npm install -g @qwen-code/qwen-code@latest`, then run `qwen`." + } "shell" => { return Ok(Availability { available: true, @@ -1985,23 +2109,26 @@ async fn agent_availability( } _ => return Err("Unknown session type".into()), }; - if !executable_exists(executable) { + if !agent_executable(&agent).is_some_and(executable_exists) { return Ok(Availability { available: false, detail: missing.into(), }); } - if agent == "codex" && provider.as_deref() == Some("deepseek") { + if agent == "codex" + && let Some(codex_provider) = codex_provider(provider.as_deref()) + { let configured = saved_api_key(&app, &agent, provider.as_deref()).is_some() - || deepseek_profile_exists(&app) - || codex_declares_deepseek(&app); + || codex_profile_exists(&app, codex_provider.id) + || codex_declares_provider(&app, codex_provider.id); return Ok(Availability { available: configured, detail: if configured { String::new() } else { format!( - "Save a DeepSeek key in Lite's settings, or add a DeepSeek provider to your Codex configuration. Either way Lite launches `{DEEPSEEK_MODEL}` through it." + "Save a {} key in Lite's settings, or add a {} provider to your Codex configuration. Either way Lite launches `{}` through it.", + codex_provider.name, codex_provider.name, codex_provider.model ) }, }); @@ -2014,15 +2141,19 @@ async fn agent_availability( #[tauri::command] async fn open_setup_docs(agent: String, provider: Option) -> Result<(), String> { + if agent == "codex" + && let Some(provider) = codex_provider(provider.as_deref()) + { + return open_external(provider.setup_url); + } let url = match (agent.as_str(), provider.as_deref()) { ("claude", _) => "https://code.claude.com/docs/en/setup", - ("codex", Some("deepseek")) => { - "https://api-docs.deepseek.com/quick_start/agent_integrations/codex" - } ("codex", _) => "https://developers.openai.com/codex/cli", + ("gemini", _) => "https://google-gemini.github.io/gemini-cli/", ("kimi", _) => { "https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html" } + ("qwen", _) => "https://qwenlm.github.io/qwen-code-docs/en/", _ => return Err("No setup guide for this session type".into()), }; open_external(url) @@ -2371,7 +2502,9 @@ fn is_agent_process(process: &sysinfo::Process, agent: &str) -> bool { .unwrap_or_default(); executable.strip_suffix(".exe").unwrap_or(executable) == agent || (agent == "codex" && executable == "codex.js") + || (agent == "gemini" && executable == "gemini.js") || (agent == "kimi" && argument.contains("kimi-code")) + || (agent == "qwen" && argument.contains("qwen-code")) }) } @@ -2404,7 +2537,10 @@ fn watch_shell_agent( session_id: String, agent: String, ) -> Result<(), String> { - if !matches!(agent.as_str(), "claude" | "codex" | "kimi") { + if !matches!( + agent.as_str(), + "claude" | "codex" | "gemini" | "kimi" | "qwen" + ) { return Err("Unknown agent".into()); } let (root, run_id, alive, agent_watch) = { @@ -2811,8 +2947,8 @@ async fn read_usage( provider: Option, session_id: String, ) -> Result, String> { - // A DeepSeek-backed Codex session bills DeepSeek, so the OpenAI account limits are not its usage. - if agent == "codex" && provider.as_deref() == Some("deepseek") { + // A custom-provider Codex session does not bill OpenAI, so OpenAI account limits are not its usage. + if agent == "codex" && codex_provider(provider.as_deref()).is_some() { return Ok(None); } match agent.as_str() { @@ -2892,7 +3028,7 @@ async fn read_usage( .then_some(usage)) } "codex" => codex_usage(&codex_server).map(Some), - "kimi" | "shell" => Ok(None), + "gemini" | "kimi" | "qwen" | "shell" => Ok(None), _ => Err("Unknown session type".into()), } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e2d2136..38eb820 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -51,7 +51,7 @@ "licenseFile": "../LICENSE", "category": "DeveloperTool", "shortDescription": "A fast, local workspace for AI coding agents.", - "longDescription": "Lite is a fast, local workspace for Claude Code, Codex on OpenAI or DeepSeek, Kimi Code, and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.", + "longDescription": "Lite is a fast, local workspace for Claude Code, Codex on OpenAI, DeepSeek, or OpenRouter, Gemini CLI, Kimi Code, Qwen Code, and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], "macOS": { "signingIdentity": "-" diff --git a/src/App.tsx b/src/App.tsx index b43e672..a3f0821 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -894,7 +894,13 @@ function commandAgent(command: string): Agent | undefined { .pop() ?.replace(/\.exe$/i, "") .toLowerCase(); - return executable === "claude" || executable === "codex" || executable === "kimi" ? executable : undefined; + return executable === "claude" || + executable === "codex" || + executable === "gemini" || + executable === "kimi" || + executable === "qwen" + ? executable + : undefined; } function App() { diff --git a/src/brand-icons.tsx b/src/brand-icons.tsx index 361b270..a3cb6da 100644 --- a/src/brand-icons.tsx +++ b/src/brand-icons.tsx @@ -1,6 +1,6 @@ // Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license -import { SquareTerminal } from "lucide-react"; +import { Route, SquareTerminal } from "lucide-react"; import type { Agent, ModelProvider } from "@/types"; @@ -17,13 +17,55 @@ export const ProviderIcon = ({ if (agent === "codex") return provider === "deepseek" ? ( + ) : provider === "openrouter" ? ( + ) : ( ); + if (agent === "gemini") return ; if (agent === "kimi") return ; + if (agent === "qwen") return ; return ; }; +// Reused from Portal's shared icon source. +const GoogleIcon = ({ className = "size-4" }: { className?: string }) => ( + + Google + + + + + +); + +const AlibabaLogomark = ({ className = "size-4" }: { className?: string }) => ( + + Alibaba + + + + +); + export const UltralyticsLogomark = ({ className = "size-4" }: { className?: string }) => ( Ultralytics diff --git a/src/inspector.tsx b/src/inspector.tsx index bdd7180..80db4b7 100644 --- a/src/inspector.tsx +++ b/src/inspector.tsx @@ -59,6 +59,7 @@ import { type DirectoryListing, type FileEntry, type GitStatus, + providerLabel, type Session, sessionLabel, } from "@/types"; @@ -882,8 +883,8 @@ function GitPanel({ rootId, sessionId, remote }: { rootId: string; sessionId: st function missingUsage(session: Session): string { if (session.agent === "shell") return "Shell sessions report no provider usage."; if (session.agent === "kimi") return "Kimi Code keeps session usage inside its own terminal view."; - if (session.agent === "codex" && session.provider === "deepseek") - return "DeepSeek publishes no account limits locally. Codex reports this session's usage in the terminal."; + if (session.agent === "codex" && session.provider && session.provider !== "openai") + return `${providerLabel(session.provider)} publishes no account limits locally. Codex reports this session's usage in the terminal.`; if (session.agent === "claude") return "Account limits appear after any Lite Claude session receives a response."; return "This provider reports no usage locally."; } @@ -920,11 +921,7 @@ function UsagePanel({ session }: { session: Session }) {
- {session.agent === "codex" - ? session.provider === "deepseek" - ? "DeepSeek" - : "OpenAI" - : sessionLabel(session)} + {session.agent === "codex" && session.provider ? providerLabel(session.provider) : sessionLabel(session)}
{error ? (

{error}

diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index c6f7caa..cc40c15 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -21,30 +21,12 @@ import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { AUTH_PROVIDERS, type AuthProviderId, type ProviderAuth, ProviderAuthDescription } from "@/provider-auth"; -import { type Agent, type ModelProvider, type Session, sessionLabel } from "@/types"; +import { AUTH_PROVIDERS, type ProviderAuth, ProviderAuthDescription } from "@/provider-auth"; +import { type Session, sessionLabel } from "@/types"; -interface Choice { - id: string; - agent: Agent; - provider?: ModelProvider; - auth?: AuthProviderId; - description?: string; - note?: string; -} - -const choices: Choice[] = [ - { id: "claude", agent: "claude", auth: "claude" }, - { id: "codex", agent: "codex", provider: "openai", auth: "codex" }, - { - id: "codex-deepseek", - agent: "codex", - provider: "deepseek", - auth: "deepseek", - note: "Runs the Codex harness against the DeepSeek provider in your Codex configuration. Usage bills DeepSeek, not OpenAI.", - }, - { id: "kimi", agent: "kimi", auth: "kimi" }, - { id: "shell", agent: "shell", description: "Open your default shell" }, +const choices = [ + ...Object.values(AUTH_PROVIDERS), + { id: "shell", agent: "shell" as const, provider: undefined, description: "Open your default shell" }, ]; // The quiet heading that separates the two questions the dialog asks, in the sidebar's own label style. @@ -226,7 +208,7 @@ export function NewSessionDialog({ {choices.map((option) => { const state = availability[option.id]; const active = option.id === choiceId; - const authProvider = option.auth ? AUTH_PROVIDERS[option.auth] : undefined; + const authProvider = "variable" in option ? option : undefined; const authStatus = authProvider ? auth?.find((entry) => entry.name === authProvider.id) : undefined; const row = ( ) : ( - {option.description} + {"description" in option ? option.description : undefined} )} @@ -260,7 +242,7 @@ export function NewSessionDialog({ ); - return option.note ? ( + return "note" in option ? ( {option.note} diff --git a/src/provider-auth.tsx b/src/provider-auth.tsx index e08b6ac..b7e8548 100644 --- a/src/provider-auth.tsx +++ b/src/provider-auth.tsx @@ -32,6 +32,26 @@ export const AUTH_PROVIDERS = { variable: "DEEPSEEK_API_KEY", configured: "Using API key", signIn: false, + note: "Runs Codex against DeepSeek. Usage bills DeepSeek, not OpenAI.", + }, + openrouter: { + id: "openrouter", + agent: "codex", + provider: "openrouter", + label: "OpenRouter", + variable: "OPENROUTER_API_KEY", + configured: "Using API key", + signIn: false, + note: "Runs Codex against OpenRouter. Usage bills OpenRouter, not OpenAI.", + }, + gemini: { + id: "gemini", + agent: "gemini", + provider: undefined, + label: "Google Gemini", + variable: "GEMINI_API_KEY", + configured: "Signed in through Gemini CLI", + signIn: true, }, kimi: { id: "kimi", @@ -42,6 +62,15 @@ export const AUTH_PROVIDERS = { configured: "Signed in through Kimi Code", signIn: true, }, + qwen: { + id: "qwen", + agent: "qwen", + provider: undefined, + label: "Alibaba ModelStudio", + variable: "DASHSCOPE_API_KEY", + configured: "Set up through Qwen Code", + signIn: true, + }, } as const satisfies Record< string, { @@ -52,6 +81,7 @@ export const AUTH_PROVIDERS = { variable: string; configured: string; signIn: boolean; + note?: string; } >; diff --git a/src/types.ts b/src/types.ts index e721275..e2a13d9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,8 @@ // Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license -// A harness runs the session; a model provider bills it. Codex is the one harness that supports both. -export type Agent = "claude" | "codex" | "kimi" | "shell"; -export type ModelProvider = "openai" | "deepseek"; +// A harness runs the session; a model provider bills it. Codex can run against multiple providers. +export type Agent = "claude" | "codex" | "gemini" | "kimi" | "qwen" | "shell"; +export type ModelProvider = "openai" | "deepseek" | "openrouter"; export interface Session { id: string; @@ -22,12 +22,24 @@ export interface Session { const agentLabels: Record = { claude: "Claude Code", codex: "Codex", + gemini: "Gemini CLI", kimi: "Kimi Code", + qwen: "Qwen Code", shell: "Shell", }; +const providerLabels: Record = { + openai: "OpenAI", + deepseek: "DeepSeek", + openrouter: "OpenRouter", +}; + +export function providerLabel(provider: ModelProvider): string { + return providerLabels[provider]; +} + export function sessionLabel({ agent, provider }: Pick): string { - if (agent === "codex" && provider === "deepseek") return "Codex · DeepSeek"; + if (agent === "codex" && provider && provider !== "openai") return `Codex · ${providerLabels[provider]}`; return agentLabels[agent]; } From ff92cb45c2081975529a9c3d2a18a03136487af7 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 17:37:53 +0200 Subject: [PATCH 02/10] Address provider review findings --- README.md | 2 +- src-tauri/src/lib.rs | 16 ++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 9b07b85..7a891b1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Ultralytics Forums](https://img.shields.io/discourse/users?server=https%3A%2F%2Fcommunity.ultralytics.com&logo=discourse&label=Forums&color=blue)](https://community.ultralytics.com) [![Ultralytics Reddit](https://img.shields.io/reddit/subreddit-subscribers/ultralytics?style=flat&logo=reddit&logoColor=white&label=Reddit&color=blue)](https://reddit.com/r/ultralytics) -Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI, [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), or [OpenRouter](https://openrouter.ai/docs/guides/guides/codex), [Gemini CLI](https://google-gemini.github.io/gemini-cli/), [Kimi Code](https://www.kimi.com/code), [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service. +Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/en/overview), [Codex](https://developers.openai.com/codex/cli) on OpenAI, [DeepSeek](https://api-docs.deepseek.com/quick_start/agent_integrations/codex), or [OpenRouter](https://openrouter.ai/docs/cookbook/coding-agents/codex-cli), [Gemini CLI](https://google-gemini.github.io/gemini-cli/), [Kimi Code](https://www.kimi.com/code), [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/), and your shell. Keep agent sessions, files, and Git context together without repository indexing, telemetry, or a cloud service.

diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8b311d3..28ff835 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -67,7 +67,7 @@ const CODEX_PROVIDERS: [CodexProvider; 2] = [ base_url: "https://openrouter.ai/api/v1", env_key: "OPENROUTER_API_KEY", model: "~openai/gpt-latest", - setup_url: "https://openrouter.ai/docs/guides/guides/codex", + setup_url: "https://openrouter.ai/docs/cookbook/coding-agents/codex-cli", }, ]; @@ -1704,12 +1704,6 @@ fn cli_auth(app: &AppHandle, name: &str) -> Option { key_hint: None, }), "kimi" => kimi_auth(app), - "qwen" => qwen_home(app) - .is_ok_and(|home| home.join("settings.json").is_file()) - .then_some(CliAuth { - method: CliAuthMethod::Provider, - key_hint: None, - }), _ => None, } } @@ -1853,10 +1847,6 @@ fn kimi_home(app: &AppHandle) -> Result { provider_home(app, "KIMI_CODE_HOME", ".kimi-code") } -fn qwen_home(app: &AppHandle) -> Result { - provider_home(app, "QWEN_HOME", ".qwen") -} - // Kimi groups sessions under an opaque per-directory key that its workspace index maps back to a path. fn kimi_workspace_key(app: &AppHandle, cwd: &Path) -> Option { let bytes = fs::read(kimi_home(app).ok()?.join("workspaces.json")).ok()?; @@ -2036,9 +2026,11 @@ fn agent_command( if let Some((_, variable)) = api_key_env(agent, provider) && let Some(key) = saved_api_key(app, agent, provider) { - command.env(variable, key); + command.env(variable, &key); if agent == "gemini" { command.env("GEMINI_DEFAULT_AUTH_TYPE", "gemini-api-key"); + } else if agent == "qwen" { + command.env("OPENAI_API_KEY", key); } } Ok(command) From e085f011edca70b9c90a6cdce7dc1747182a204f Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:03:16 +0200 Subject: [PATCH 03/10] Install missing agent harnesses --- src-tauri/src/lib.rs | 65 ++++++++++++++++++++++++++++++++++++++ src/brand-icons.tsx | 14 ++++++-- src/new-session-dialog.tsx | 43 +++++++++++++++++++++---- 3 files changed, 113 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28ff835..d791d3f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2068,6 +2068,7 @@ fn login_command(agent: &str) -> Result { #[serde(rename_all = "camelCase")] struct Availability { available: bool, + installable: bool, detail: String, } @@ -2096,6 +2097,7 @@ async fn agent_availability( "shell" => { return Ok(Availability { available: true, + installable: false, detail: String::new(), }); } @@ -2104,6 +2106,7 @@ async fn agent_availability( if !agent_executable(&agent).is_some_and(executable_exists) { return Ok(Availability { available: false, + installable: true, detail: missing.into(), }); } @@ -2115,6 +2118,7 @@ async fn agent_availability( || codex_declares_provider(&app, codex_provider.id); return Ok(Availability { available: configured, + installable: false, detail: if configured { String::new() } else { @@ -2127,10 +2131,70 @@ async fn agent_availability( } Ok(Availability { available: true, + installable: false, detail: String::new(), }) } +#[tauri::command] +async fn install_agent(agent: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let mut command = if agent == "kimi" { + #[cfg(windows)] + { + let powershell = resolve_executable("powershell") + .ok_or("Could not find PowerShell in your PATH")?; + let mut command = Command::new(powershell); + command.args([ + "-NoProfile", + "-Command", + "irm https://code.kimi.com/kimi-code/install.ps1 | iex", + ]); + command + } + #[cfg(unix)] + { + let mut command = Command::new("/bin/sh"); + command.args([ + "-c", + "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash", + ]); + command + } + } else { + let package = match agent.as_str() { + "claude" => "@anthropic-ai/claude-code", + "codex" => "@openai/codex", + "gemini" => "@google/gemini-cli", + "qwen" => "@qwen-code/qwen-code@latest", + _ => return Err("This session type cannot be installed automatically".into()), + }; + let npm = resolve_executable("npm").ok_or("Install Node.js and npm first")?; + let mut command = Command::new(npm); + command.args(["install", "-g", package]); + command + }; + if let Some(path) = user_path() { + command.env("PATH", path); + } + let output = command + .output() + .map_err(|error| format!("Could not run the installer: {error}"))?; + if output.status.success() { + return Ok(()); + } + let detail = String::from_utf8_lossy(&output.stderr); + let detail = detail.trim(); + Err(if detail.is_empty() { + format!("The {agent} installer exited with {}", output.status) + } else { + detail.to_owned() + }) + }) + .await + .map_err(|error| format!("Could not finish the install: {error}"))? +} + #[tauri::command] async fn open_setup_docs(agent: String, provider: Option) -> Result<(), String> { if agent == "codex" @@ -3225,6 +3289,7 @@ pub fn run() { git_remote, read_usage, agent_availability, + install_agent, open_setup_docs, open_url, open_directory, diff --git a/src/brand-icons.tsx b/src/brand-icons.tsx index a3cb6da..f8ea5e1 100644 --- a/src/brand-icons.tsx +++ b/src/brand-icons.tsx @@ -1,6 +1,6 @@ // Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license -import { Route, SquareTerminal } from "lucide-react"; +import { SquareTerminal } from "lucide-react"; import type { Agent, ModelProvider } from "@/types"; @@ -18,7 +18,7 @@ export const ProviderIcon = ({ return provider === "deepseek" ? ( ) : provider === "openrouter" ? ( - + ) : ( ); @@ -28,6 +28,16 @@ export const ProviderIcon = ({ return ; }; +const OpenRouterLogomark = ({ className = "size-4" }: { className?: string }) => ( + + OpenRouter + + +); + // Reused from Portal's shared icon source. const GoogleIcon = ({ className = "size-4" }: { className?: string }) => ( diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index cc40c15..99f742b 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -39,6 +39,7 @@ interface DirectoryGrant { interface Availability { available: boolean; + installable: boolean; detail: string; } @@ -56,10 +57,11 @@ export function NewSessionDialog({ const [path, setPath] = useState(""); const [availability, setAvailability] = useState>({}); const [auth, setAuth] = useState(); + const [installing, setInstalling] = useState(""); const [error, setError] = useState(""); const choice = choices.find((option) => option.id === choiceId) ?? choices[0]; const status = availability[choice.id]; - // An agent that is not installed cannot take a session yet, so the dialog offers its setup guide instead. + // An agent that is not installed cannot take a session yet, so the dialog offers to install it instead. const missing = status && !status.available ? status : undefined; useEffect(() => { @@ -129,6 +131,7 @@ export function NewSessionDialog({ } function changeOpen(open: boolean) { + if (!open && installing) return; if (!open && directory) { void invoke("revoke_directory", { rootId: directory.id }); setDirectory(undefined); @@ -153,12 +156,31 @@ export function NewSessionDialog({ onOpenChange(false); } + async function install() { + setInstalling(choice.id); + setError(""); + try { + await invoke("install_agent", { agent: choice.agent }); + const result = await invoke("agent_availability", { + agent: choice.agent, + provider: choice.provider, + }); + setAvailability((current) => ({ ...current, [choice.id]: result })); + if (!result.available) setError(result.detail); + } catch (reason) { + setError(String(reason)); + } finally { + setInstalling(""); + } + } + // Everything the dialog can be asked for arrives here: the submit button, Enter from the folder field, // and a second click on the agent already chosen. An agent that is not installed reads them all as a - // request for its setup guide, which is the only one of the two it can answer. - const ready = Boolean(missing || (path.trim() && status)); + // request to install it, which is the only one of the two it can answer. + const ready = Boolean(!installing && (missing || (path.trim() && status))); function start() { - if (missing) + if (missing?.installable) void install(); + else if (missing) void invoke("open_setup_docs", { agent: choice.agent, provider: choice.provider }).catch((reason) => setError(String(reason)), ); @@ -220,6 +242,7 @@ export function NewSessionDialog({

{error}

: null} - From baed7da937a0f5ce8fa46b60968bcd5bb72db9ce Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:12:44 +0200 Subject: [PATCH 04/10] Address final harness review findings --- README.md | 2 +- src-tauri/src/lib.rs | 102 ++++++++++++++++++++++++++++++++++++++----- src/brand-icons.tsx | 4 +- 3 files changed, 93 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 7a891b1..8771cc2 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ Install the provider CLIs you want to use: - [Kimi Code](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html) — Windows also needs Git for Windows - [Qwen Code](https://qwenlm.github.io/qwen-code-docs/en/) -Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog tells you when a CLI is missing and links to its setup guide. +Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog installs a missing CLI for you. If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `MOONSHOT_API_KEY`, or `DASHSCOPE_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d791d3f..4dad2c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1373,21 +1373,69 @@ fn shell_path(shell: &str) -> Option { // An account shell that does not speak this command leaves nothing usable, so the search falls back to // the POSIX shell rather than losing every provider CLI. -// Asking costs a login shell, and the answer holds for as long as Lite is open, so it is asked once -// and every command Lite resolves afterwards is free. +// Asking costs a login shell, so the answer is cached until Lite itself installs a CLI that changes it. #[cfg(unix)] fn user_path() -> Option { - static PATH: std::sync::OnceLock> = std::sync::OnceLock::new(); + path_cache().lock().ok().and_then(|path| path.clone()) +} + +#[cfg(unix)] +fn path_cache() -> &'static Mutex> { + static PATH: std::sync::OnceLock>> = std::sync::OnceLock::new(); PATH.get_or_init(|| { - shell_path(&CommandBuilder::new_default_prog().get_shell()) - .or_else(|| shell_path("/bin/sh")) + Mutex::new( + shell_path(&CommandBuilder::new_default_prog().get_shell()) + .or_else(|| shell_path("/bin/sh")), + ) }) - .clone() +} + +#[cfg(unix)] +fn refresh_user_path() { + let refreshed = shell_path(&CommandBuilder::new_default_prog().get_shell()) + .or_else(|| shell_path("/bin/sh")); + if let Ok(mut path) = path_cache().lock() { + *path = refreshed; + } } #[cfg(windows)] fn user_path() -> Option { - std::env::var("PATH").ok() + path_cache() + .lock() + .ok() + .and_then(|path| path.clone()) + .or_else(|| std::env::var("PATH").ok()) +} + +#[cfg(windows)] +fn path_cache() -> &'static Mutex> { + static PATH: std::sync::OnceLock>> = std::sync::OnceLock::new(); + PATH.get_or_init(|| Mutex::new(None)) +} + +#[cfg(windows)] +fn refresh_user_path() { + let refreshed = Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + "[Environment]::GetEnvironmentVariable('Path','User')", + ]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|path| path.trim().to_owned()) + .filter(|path| !path.is_empty()); + if let Some(refreshed) = refreshed + && let Ok(mut path) = path_cache().lock() + { + *path = Some(format!( + "{refreshed};{}", + std::env::var("PATH").unwrap_or_default() + )); + } } // Keys live in one owner-only file beside Lite's other local state, which is what the Codex and Kimi @@ -1704,6 +1752,12 @@ fn cli_auth(app: &AppHandle, name: &str) -> Option { key_hint: None, }), "kimi" => kimi_auth(app), + "qwen" => qwen_home(app) + .is_ok_and(|home| home.join("oauth_creds.json").is_file()) + .then_some(CliAuth { + method: CliAuthMethod::Provider, + key_hint: None, + }), _ => None, } } @@ -1843,6 +1897,10 @@ fn gemini_home(app: &AppHandle) -> Result { provider_home(app, "GEMINI_CLI_HOME", ".gemini") } +fn qwen_home(app: &AppHandle) -> Result { + provider_home(app, "QWEN_HOME", ".qwen") +} + fn kimi_home(app: &AppHandle) -> Result { provider_home(app, "KIMI_CODE_HOME", ".kimi-code") } @@ -2177,16 +2235,36 @@ async fn install_agent(agent: String) -> Result<(), String> { if let Some(path) = user_path() { command.env("PATH", path); } - let output = command - .output() + command.stdout(Stdio::null()).stderr(Stdio::piped()); + let mut child = command + .spawn() .map_err(|error| format!("Could not run the installer: {error}"))?; - if output.status.success() { + let mut stderr = child + .stderr + .take() + .ok_or("Could not read installer errors")?; + let mut detail = Vec::with_capacity(16_384); + let mut buffer = [0_u8; 4096]; + while let Ok(count) = stderr.read(&mut buffer) { + if count == 0 { + break; + } + detail.extend_from_slice(&buffer[..count]); + if detail.len() > 16_384 { + detail.drain(..detail.len() - 16_384); + } + } + let status = child + .wait() + .map_err(|error| format!("Could not finish the installer: {error}"))?; + if status.success() { + refresh_user_path(); return Ok(()); } - let detail = String::from_utf8_lossy(&output.stderr); + let detail = String::from_utf8_lossy(&detail); let detail = detail.trim(); Err(if detail.is_empty() { - format!("The {agent} installer exited with {}", output.status) + format!("The {agent} installer exited with {status}") } else { detail.to_owned() }) diff --git a/src/brand-icons.tsx b/src/brand-icons.tsx index f8ea5e1..2dbc503 100644 --- a/src/brand-icons.tsx +++ b/src/brand-icons.tsx @@ -29,11 +29,11 @@ export const ProviderIcon = ({ }; const OpenRouterLogomark = ({ className = "size-4" }: { className?: string }) => ( - + OpenRouter ); From c43503a2afa01efaa5ea83274e0588f080018be8 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:19:15 +0200 Subject: [PATCH 05/10] Preserve Qwen region configuration --- README.md | 2 +- src-tauri/src/lib.rs | 35 ++++++++++++++++++++++------------- src/new-session-dialog.tsx | 2 +- src/provider-auth.tsx | 3 +-- src/settings-dialog.tsx | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8771cc2..cb17193 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Install the provider CLIs you want to use: Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog installs a missing CLI for you. -If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, `MOONSHOT_API_KEY`, or `DASHSCOPE_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data. +If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, or `MOONSHOT_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data.
Saving provider API keys in Lite diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4dad2c6..d37679f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,16 +30,13 @@ const CODEX_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); // Requests stay bounded so an app server that never answers surfaces an error instead of a stuck tab. const CODEX_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); const DEEPSEEK_MODEL: &str = "deepseek-v4-flash"; -const QWEN_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"; -const QWEN_MODEL: &str = "qwen3.7-plus"; -const SUPPORTED_KEYS: [&str; 7] = [ +const SUPPORTED_KEYS: [&str; 6] = [ "claude", "codex", "deepseek", "openrouter", "gemini", "kimi", - "qwen", ]; #[derive(Clone, Copy)] @@ -1565,7 +1562,6 @@ fn api_key_env(agent: &str, provider: Option<&str>) -> Option<(&'static str, &'s ("codex", _) => Some(("codex", "OPENAI_API_KEY")), ("gemini", _) => Some(("gemini", "GEMINI_API_KEY")), ("kimi", _) => Some(("kimi", "MOONSHOT_API_KEY")), - ("qwen", _) => Some(("qwen", "DASHSCOPE_API_KEY")), _ => None, } } @@ -2062,11 +2058,6 @@ fn agent_command( } else { ["--session-id", session_id] }); - if saved_api_key(app, agent, provider).is_some() { - command.args(["--auth-type", "openai"]); - command.args(["--openai-base-url", QWEN_BASE_URL]); - command.args(["--model", QWEN_MODEL]); - } command } "shell" => { @@ -2087,8 +2078,6 @@ fn agent_command( command.env(variable, &key); if agent == "gemini" { command.env("GEMINI_DEFAULT_AUTH_TYPE", "gemini-api-key"); - } else if agent == "qwen" { - command.env("OPENAI_API_KEY", key); } } Ok(command) @@ -2259,7 +2248,27 @@ async fn install_agent(agent: String) -> Result<(), String> { .map_err(|error| format!("Could not finish the installer: {error}"))?; if status.success() { refresh_user_path(); - return Ok(()); + let executable = agent_executable(&agent).ok_or("Unknown session type")?; + let path = resolve_executable(executable) + .ok_or_else(|| format!("Installed {agent}, but could not find it in your PATH"))?; + let mut probe = Command::new(path); + probe + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()); + if let Some(path) = user_path() { + probe.env("PATH", path); + } + return probe + .status() + .map_err(|error| format!("Installed {agent}, but could not run it: {error}"))? + .success() + .then_some(()) + .ok_or_else(|| { + format!( + "Installed {agent}, but it cannot run with the current system requirements" + ) + }); } let detail = String::from_utf8_lossy(&detail); let detail = detail.trim(); diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index 99f742b..0ec73e4 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -230,7 +230,7 @@ export function NewSessionDialog({ {choices.map((option) => { const state = availability[option.id]; const active = option.id === choiceId; - const authProvider = "variable" in option ? option : undefined; + const authProvider = "configured" in option ? option : undefined; const authStatus = authProvider ? auth?.find((entry) => entry.name === authProvider.id) : undefined; const row = ( "variable" in provider); export function SettingsDialog({ open: isOpen, From 13376db2cbc2b9adac9d639d4f07371717a6c6ad Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:25:30 +0200 Subject: [PATCH 06/10] Make session restarts undoable --- src/App.tsx | 52 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index a3f0821..d05fd7e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1317,18 +1317,11 @@ function App() { } // Restarting keeps the tab and its folder but asks the provider for a conversation of its own, so the - // session it resumed by id is forgotten first and the tab takes a new one. + // tab takes a new id. The old provider metadata stays until Undo expires so the original can resume. async function restartSession(session: Session, select = true) { runs.current.delete(session.id); setSessions((current) => current.map((item) => (item.id === session.id ? { ...item, running: false } : item))); await invoke("stop_session", { sessionId: session.id }); - try { - await invoke("delete_session_data", { sessionId: session.id }); - } catch (reason) { - setError(String(reason)); - } - clearOutput(session.id); - clearUsageCache(session.id); const fresh: Session = { ...session, id: crypto.randomUUID(), providerSessionId: undefined, running: false }; setSessions((current) => current.map((item) => (item.id === session.id ? fresh : item))); if (select) { @@ -1337,6 +1330,49 @@ function App() { } await launch(fresh, false); if (fresh.agent === "kimi") startKimiConversation(fresh.id); + + let undone = false; + async function cleanupRestarted() { + try { + await invoke("delete_session_data", { sessionId: session.id }); + } catch (reason) { + setError(`Session restarted, but local cleanup failed: ${String(reason)}`); + } + clearOutput(session.id); + clearUsageCache(session.id); + } + toast.add({ + title: `Restarted “${session.name}”`, + type: "success", + timeout: 8000, + onClose: async () => { + if (!undone) await cleanupRestarted(); + }, + actionProps: { + children: "Undo", + onClick: async () => { + undone = true; + const runId = runs.current.get(fresh.id); + runs.current.delete(fresh.id); + try { + await invoke("stop_session", { sessionId: fresh.id }); + } catch (reason) { + if (runId) runs.current.set(fresh.id, runId); + await cleanupRestarted(); + setError(`Restart could not be undone: ${String(reason)}`); + return; + } + await invoke("delete_session_data", { sessionId: fresh.id }).catch((reason) => setError(String(reason))); + clearOutput(fresh.id); + clearUsageCache(fresh.id); + const restored = { ...session, running: false }; + setSessions((current) => current.map((item) => (item.id === fresh.id ? restored : item))); + setSelectedId((current) => (current === fresh.id ? session.id : current)); + resumed.current = session.id; + await launch(restored, true); + }, + }, + }); } async function restartAllSessions() { From 2de467991dc31e78c419db029428a9a65c8b7a5d Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:31:14 +0200 Subject: [PATCH 07/10] Refresh shared provider status --- src-tauri/src/lib.rs | 6 ++++-- src/new-session-dialog.tsx | 23 +++++++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d37679f..275c964 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1772,12 +1772,14 @@ async fn provider_auth(app: AppHandle) -> Result, String> { let keys = load_api_keys(&app); Ok(SUPPORTED_KEYS .iter() + .copied() + .chain(["qwen"]) .map(|name| { let cli_auth = cli_auth(&app, name); ProviderAuth { - name: (*name).to_owned(), + name: name.to_owned(), // Only the last characters travel to the interface, enough to tell two keys apart. - key_hint: keys.get(*name).map(|key| key_hint(key)), + key_hint: keys.get(name).map(|key| key_hint(key)), cli_auth_method: cli_auth.as_ref().map(|auth| auth.method), cli_key_hint: cli_auth.and_then(|auth| auth.key_hint), } diff --git a/src/new-session-dialog.tsx b/src/new-session-dialog.tsx index 0ec73e4..dcad750 100644 --- a/src/new-session-dialog.tsx +++ b/src/new-session-dialog.tsx @@ -161,12 +161,23 @@ export function NewSessionDialog({ setError(""); try { await invoke("install_agent", { agent: choice.agent }); - const result = await invoke("agent_availability", { - agent: choice.agent, - provider: choice.provider, - }); - setAvailability((current) => ({ ...current, [choice.id]: result })); - if (!result.available) setError(result.detail); + const results = await Promise.all( + choices + .filter((option) => option.agent === choice.agent) + .map( + async (option) => + [ + option.id, + await invoke("agent_availability", { + agent: option.agent, + provider: option.provider, + }), + ] as const, + ), + ); + setAvailability((current) => ({ ...current, ...Object.fromEntries(results) })); + const result = results.find(([id]) => id === choice.id)?.[1]; + if (result && !result.available) setError(result.detail); } catch (reason) { setError(String(reason)); } finally { From b7637f6b73d110f3f5dd8bc9f22f56c50b748d37 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:39:45 +0200 Subject: [PATCH 08/10] Handle missing provider sessions --- src-tauri/src/lib.rs | 56 ++++++++++++++++++++++++++++++++++++++++++-- src/App.tsx | 18 +++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 275c964..013bdc1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1899,6 +1899,53 @@ fn qwen_home(app: &AppHandle) -> Result { provider_home(app, "QWEN_HOME", ".qwen") } +fn native_session_exists(app: &AppHandle, agent: &str, session_id: &str) -> bool { + let (home, directory) = match agent { + "gemini" => (gemini_home(app), "tmp"), + "qwen" => (qwen_home(app), "projects"), + _ => return false, + }; + let Ok(projects) = + home.and_then(|home| fs::read_dir(home.join(directory)).map_err(|error| error.to_string())) + else { + return false; + }; + let expected = if agent == "qwen" { + format!("{session_id}.jsonl") + } else { + format!("-{}.jsonl", session_id.chars().take(8).collect::()) + }; + projects.flatten().take(512).any(|project| { + fs::read_dir(project.path().join("chats")).is_ok_and(|chats| { + chats.flatten().take(512).any(|chat| { + let name = chat.file_name(); + let name = name.to_string_lossy(); + if agent == "qwen" { + name == expected + } else { + name.ends_with(&expected) + && fs::File::open(chat.path()).is_ok_and(|file| { + BufReader::new(file) + .lines() + .next() + .and_then(Result::ok) + .and_then(|line| { + serde_json::from_str::(&line).ok() + }) + .and_then(|value| { + value + .get("sessionId") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + .is_some_and(|id| id == session_id) + }) + } + }) + }) + }) +} + fn kimi_home(app: &AppHandle) -> Result { provider_home(app, "KIMI_CODE_HOME", ".kimi-code") } @@ -2380,8 +2427,13 @@ async fn spawn_session( let cwd = root_path(&roots, &root_id)?; // A sign-in runs the provider's own login command and owns no session of its own. let signing_in = mode.as_deref() == Some("login"); - // Resuming a Claude session it never recorded fails outright, so that tab starts one instead. - let resume = resume && (agent != "claude" || claude_session_exists(&app, &session_id)); + // A tab whose provider history was removed starts again instead of becoming permanently unusable. + let resume = resume + && match agent.as_str() { + "claude" => claude_session_exists(&app, &session_id), + "gemini" | "qwen" => native_session_exists(&app, &agent, &session_id), + _ => true, + }; if !signing_in && (agent == "codex" || agent == "kimi") { if let Some(saved_provider_session_id) = provider_sessions .0 diff --git a/src/App.tsx b/src/App.tsx index d05fd7e..6906f3c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1219,7 +1219,7 @@ function App() { }, [markDirectory, markWorking, markTitle]); const launch = useCallback(async (session: Session, resume: boolean) => { - if (runs.current.has(session.id)) return; + if (runs.current.has(session.id)) return true; const runId = crypto.randomUUID(); runs.current.set(session.id, runId); setStartingIds((current) => new Set(current).add(session.id)); @@ -1239,7 +1239,7 @@ function App() { cols: 100, rows: 30, }); - if (runs.current.get(session.id) !== runId) return; + if (runs.current.get(session.id) !== runId) return false; setSessions((current) => current.map((item) => item.id === session.id @@ -1251,10 +1251,12 @@ function App() { : item, ), ); + return true; } catch (reason) { if (runs.current.get(session.id) === runId) runs.current.delete(session.id); setSessions((current) => current.map((item) => (item.id === session.id ? { ...item, running: false } : item))); setError(String(reason)); + return false; } finally { setStartingIds((current) => { const next = new Set(current); @@ -1328,7 +1330,17 @@ function App() { setSelectedId(fresh.id); resumed.current = fresh.id; } - await launch(fresh, false); + if (!(await launch(fresh, false))) { + await invoke("delete_session_data", { sessionId: fresh.id }).catch(() => {}); + clearOutput(fresh.id); + clearUsageCache(fresh.id); + const restored = { ...session, running: false }; + setSessions((current) => current.map((item) => (item.id === fresh.id ? restored : item))); + setSelectedId((current) => (current === fresh.id ? session.id : current)); + resumed.current = session.id; + if (await launch(restored, true)) setError("Restart failed; the original session was restored."); + return; + } if (fresh.agent === "kimi") startKimiConversation(fresh.id); let undone = false; From 1b1af1d9389d1e49d13ffd387b3330b2ecab80d3 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:43:34 +0200 Subject: [PATCH 09/10] Defer session actions with shared undo --- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src/App.tsx | 143 +++++++++++++++---------------------------- 3 files changed, 53 insertions(+), 94 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b205612..0eb9544 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1857,7 +1857,7 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "lite" -version = "0.0.13" +version = "0.0.14" dependencies = [ "atomicwrites", "portable-pty", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 03adbf9..25586fd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "lite" -version = "0.0.13" +version = "0.0.14" description = "A fast, local workspace for AI coding agents." authors = ["Ultralytics"] edition = "2024" diff --git a/src/App.tsx b/src/App.tsx index 6906f3c..b437c0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1318,12 +1318,42 @@ function App() { void launch(session, false); } + function deferSessionAction(session: Session, action: "Close" | "Restart", perform: () => Promise) { + let undone = false; + toast.add({ + title: `${action} “${session.name}”`, + type: "success", + timeout: 8000, + onClose: async () => { + if (!undone) await perform(); + }, + actionProps: { + children: "Undo", + onClick: () => { + undone = true; + }, + }, + }); + } + // Restarting keeps the tab and its folder but asks the provider for a conversation of its own, so the - // tab takes a new id. The old provider metadata stays until Undo expires so the original can resume. - async function restartSession(session: Session, select = true) { + // tab takes a new id. Nothing changes until the shared Undo window expires. + function restartSession(session: Session, select = true) { + deferSessionAction(session, "Restart", () => restartSessionNow(session, select)); + } + + async function restartSessionNow(session: Session, select: boolean) { runs.current.delete(session.id); setSessions((current) => current.map((item) => (item.id === session.id ? { ...item, running: false } : item))); - await invoke("stop_session", { sessionId: session.id }); + try { + await invoke("stop_session", { sessionId: session.id }); + } catch (reason) { + setSessions((current) => + current.map((item) => (item.id === session.id ? { ...item, running: session.running } : item)), + ); + setError(`Session could not be restarted: ${String(reason)}`); + return; + } const fresh: Session = { ...session, id: crypto.randomUUID(), providerSessionId: undefined, running: false }; setSessions((current) => current.map((item) => (item.id === session.id ? fresh : item))); if (select) { @@ -1342,53 +1372,15 @@ function App() { return; } if (fresh.agent === "kimi") startKimiConversation(fresh.id); - - let undone = false; - async function cleanupRestarted() { - try { - await invoke("delete_session_data", { sessionId: session.id }); - } catch (reason) { - setError(`Session restarted, but local cleanup failed: ${String(reason)}`); - } - clearOutput(session.id); - clearUsageCache(session.id); - } - toast.add({ - title: `Restarted “${session.name}”`, - type: "success", - timeout: 8000, - onClose: async () => { - if (!undone) await cleanupRestarted(); - }, - actionProps: { - children: "Undo", - onClick: async () => { - undone = true; - const runId = runs.current.get(fresh.id); - runs.current.delete(fresh.id); - try { - await invoke("stop_session", { sessionId: fresh.id }); - } catch (reason) { - if (runId) runs.current.set(fresh.id, runId); - await cleanupRestarted(); - setError(`Restart could not be undone: ${String(reason)}`); - return; - } - await invoke("delete_session_data", { sessionId: fresh.id }).catch((reason) => setError(String(reason))); - clearOutput(fresh.id); - clearUsageCache(fresh.id); - const restored = { ...session, running: false }; - setSessions((current) => current.map((item) => (item.id === fresh.id ? restored : item))); - setSelectedId((current) => (current === fresh.id ? session.id : current)); - resumed.current = session.id; - await launch(restored, true); - }, - }, - }); + await invoke("delete_session_data", { sessionId: session.id }).catch((reason) => + setError(`Session restarted, but local cleanup failed: ${String(reason)}`), + ); + clearOutput(session.id); + clearUsageCache(session.id); } async function restartAllSessions() { - await Promise.all(sessions.map((session) => restartSession(session, session.id === selectedId))); + for (const session of sessions) restartSession(session, session.id === selectedId); } async function cleanupSession(session: Session) { @@ -1408,13 +1400,20 @@ function App() { if (cleanupError) setError(`Session closed, but local cleanup failed: ${cleanupError}`); } - // Closing a session is reversible: the row leaves immediately and its PTY stops, while the - // provider session metadata and directory grant remain until the toast closes without being undone. + // Closing uses the same deferred action as restarting: Undo leaves the running terminal untouched. function closeSession(session: Session) { - const index = sessions.findIndex((item) => item.id === session.id); + deferSessionAction(session, "Close", () => closeSessionNow(session)); + } + + async function closeSessionNow(session: Session) { const wasSelected = selectedId === session.id; const nextSelectedId = sessions.find((item) => item.id !== session.id)?.id ?? ""; - const runId = runs.current.get(session.id); + try { + await invoke("stop_session", { sessionId: session.id }); + } catch (reason) { + setError(`Session could not be closed: ${String(reason)}`); + return; + } runs.current.delete(session.id); if (resumed.current === session.id) resumed.current = ""; const timer = workTimers.current.get(session.id); @@ -1427,47 +1426,7 @@ function App() { }); setSessions((current) => current.filter((item) => item.id !== session.id)); if (wasSelected) setSelectedId(nextSelectedId); - - let undone = false; - let toastId = ""; - function restore(running: boolean) { - setSessions((current) => { - if (current.some((item) => item.id === session.id)) return current; - const restored = [...current]; - restored.splice(Math.min(index, restored.length), 0, { ...session, running }); - return restored; - }); - if (wasSelected) setSelectedId((current) => (current === nextSelectedId ? session.id : current)); - } - const stopped = invoke("stop_session", { sessionId: session.id }).then( - () => true, - (reason) => { - undone = true; - if (runId) { - runs.current.set(session.id, runId); - resumed.current = session.id; - } - restore(session.running); - setError(`Session could not be closed: ${String(reason)}`); - toast.close(toastId); - return false; - }, - ); - toastId = toast.add({ - title: `Closed “${session.name}”`, - type: "success", - timeout: 8000, - onClose: async () => { - if ((await stopped) && !undone) await cleanupSession(session); - }, - actionProps: { - children: "Undo", - onClick: async () => { - undone = true; - if (await stopped) restore(false); - }, - }, - }); + await cleanupSession(session); } async function checkForUpdates() { From eeef519c803cd8ae89f626178fb294dda44dd10a Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 9 Aug 2026 18:48:21 +0200 Subject: [PATCH 10/10] Document deferred session actions --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb17193..7afe685 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ Lite is a fast, local workspace for [Claude Code](https://code.claude.com/docs/e - Run Claude Code, Codex, Gemini CLI, Kimi Code, Qwen Code, and shell sessions side by side - Run Codex against DeepSeek or OpenRouter without changing your default Codex provider - Resume session tabs automatically after closing Lite or restarting your computer +- Undo a restart or close for eight seconds before Lite stops the terminal - Authenticate once with each provider and reuse its existing local credentials -- Or save an API key per provider in Lite and skip the sign-in flows entirely +- Or save API keys for supported providers in Lite and skip their sign-in flows entirely - Browse files on demand with syntax highlighting for popular languages - Preview rendered Markdown safely alongside source files - See the active Git branch, worktree, and changed files @@ -79,7 +80,7 @@ Install the provider CLIs you want to use: Open each provider in Lite and complete its normal sign-in once. Every CLI keeps credentials in its own local store, so later Lite sessions reuse the same authentication. Lite never reads or copies those stores. The new-session dialog installs a missing CLI for you. -If you would rather use API keys, open **API keys** in the Lite menu and paste one per provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, or `MOONSHOT_API_KEY`). Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data. +If you would rather use API keys, open **API keys** in the Lite menu and paste one for a supported provider. Lite keeps them in an owner-only file in its own data folder — the same shape Codex and Kimi already use for their credentials — and passes a key to a session through the environment variable that CLI already reads (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, or `MOONSHOT_API_KEY`). Qwen Code keeps ownership of its regional provider and authentication setup. Nothing is written into provider configuration, deleting a key takes effect on the next launch, and app updates keep the file since the updater replaces the bundle and not your data.
Saving provider API keys in Lite