1 line
223 KiB
JSON
1 line
223 KiB
JSON
{"repo": "bloom500/feral", "files": {"crates/feral-core/src/byok.rs": {"634a54215402d9f116ba6599a5c75c718098fcff": {"t": 1785746229.0905452, "n": 45431, "h": "ae58fd7bd2b91b43", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n\n /// THE canonical provider-id → `Provider` mapping. Every call site that\n /// receives a provider id string (IPC commands, HTTP routes, CLI) must\n /// resolve through here — hand-rolled `match provider_id` copies drifted\n /// (three sites were missing `\"nvidia\"` and fell through to `Custom`,\n /// whose default base URL is a placeholder). Unknown ids map to\n /// `Custom`, which only works with an explicit user-supplied base URL.\n pub fn from_id(id: &str) -> Provider {\n match id {\n \"openai\" => Provider::Openai,\n \"anthropic\" => Provider::Anthropic,\n \"google\" => Provider::Google,\n \"kimi\" => Provider::Kimi,\n \"glm\" => Provider::Glm,\n \"minimax\" => Provider::Minimax,\n \"groq\" => Provider::Groq,\n \"mistral\" => Provider::Mistral,\n \"deepseek\" => Provider::Deepseek,\n \"openrouter\" => Provider::Openrouter,\n \"nvidia\" => Provider::Nvidia,\n _ => Provider::Custom,\n }\n }\n\n /// Protocol family as the sidecar's `set_model` handler understands it\n /// (`FeralAgent/src/sandbox/inference-router.ts` `#providers` map:\n /// unknown families default to OpenAI-compatible). Single source for\n /// the `provider_kind` previously matched inline in `api.rs`.\n pub fn family(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"anthropic\",\n Provider::Google => \"google\",\n _ => \"openai_compatible\",\n }\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers.\n /// Derived from `provider_catalog()` so the wizard catalog and the\n /// settings page can never list different providers.\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n provider_catalog()\n .into_iter()\n .map(|e| (e.id, e.name, e.provider))\n .collect()\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n// ── Public provider catalog (Phase 1, 2026-07-07) ───────────────────────────\n//\n// The canonical source of \"which providers exist and what to show for each\"\n// lives in Rust and is exposed via `GET /runtime/providers/catalog`. The\n// Go TUI wizard and the desktop React OnboardingWizard both consume this\n// catalog instead of maintaining their own parallel slices — that's the\n// drift surface that the terminal-onboarding plan calls out as the\n// root cause of the stale `qwen2.5:7b` placeholder reaching the wizard.\n//\n// Clients pin the catalog version via the `X-Feral-Catalog-Version`\n// response header (see `api.rs::runtime_providers_catalog`). On a\n// version mismatch the client falls back to its bundled slice with a\n// clearly surfaced warning rather than silently dropping providers.\n\n/// Catalog version. Bumped whenever a field is added/removed/renamed\n/// in [`ProviderCatalogEntry`]. Currently `1`.\npub const CATALOG_VERSION: u32 = 1;\n\n/// Auth header style the wizard should emit when constructing the\n/// validation probe (`/providers/test`). Drives the rendered header line\n/// and the key format hint, not the actual HTTP transport (which is\n/// handled by `Provider::api_key_header` + `Provider::api_key_prefix`\n/// once the user submits).\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthStyle {\n /// `Authorization: Bearer <key>` — OpenAI / Groq / Mistral / DeepSeek /\n /// OpenRouter / Kimi / GLM / MiniMax / NVIDIA NIM / Google / Custom.\n Bearer,\n /// `x-api-key: <key>` (plus `anthropic-version: 2023-06-01`) — Anthropic.\n XApiKey,\n}\n\n/// One row of the public provider catalog.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderCatalogEntry {\n pub id: String,\n /// Display name shown in the picker (e.g. \"OpenAI\", \"Anthropic\",\n /// \"NVIDIA NIM\"). Distinct from `id` because `provider_kind` and\n /// display name don't always match (\"minimax\" → \"MiniMax\").\n pub name: String,\n /// The typed `Provider` enum. Duplicated with `id` so callers that\n /// already have a `Provider` value can do an O(1) lookup without\n /// string matching.\n pub provider: Provider,\n /// Default API base URL. Cached here so the catalog row is\n /// self-contained — the gateway doesn't need a second\n /// `Provider::default_base_url()` round-trip to render a card.\n pub default_base_url: String,\n /// Wizard's chosen-by-default model when the user hasn't picked\n /// one yet. The runtime replaces it with whatever the user saved\n /// in `byok.json`.\n pub default_model: String,\n /// Where to manage API keys (e.g. `https://platform.openai.com/api-keys`).\n /// None if the provider doesn't publish a stable console URL.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub console_url: Option<String>,\n /// Recognised key prefix (e.g. `\"sk-\"` for OpenAI, `\"sk-ant-\"` for\n /// Anthropic). Client may render a hint next to the input.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format: Option<String>,\n /// Placeholder text shown in the key-entry input, e.g.\n /// `\"Begins with sk-ant-…\"`. Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format_hint: Option<String>,\n /// Free-tier note shown on the provider card, if any. Examples:\n /// \"Free trial credits — no card required.\"\n /// Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub free_tier_note: Option<String>,\n /// Whether the wizard should accept a custom base URL. False for\n /// hosted-only providers (Anthropic); true for self-host-friendly\n /// ones (OpenAI-compatible providers, where the user might want to\n /// point at a private deployment).\n pub supports_custom_base_url: bool,\n pub auth_style: AuthStyle,\n}\n\n/// Returns the canonical, deduplicated provider catalog.\n///\n/// This is the **single source of truth** for \"which providers can the\n/// user pick in the wizard?\" Adding a new provider = one new entry here plus\n/// a gateway rebuild. TUI + desktop pick it up automatically without code\n/// changes on their side.\n///\n/// `Provider::default_base_url()` remains the canonical per-variant URL\n/// (used by the runtime to issue requests); the catalog row carries a\n/// copy as `default_base_url: String` so the API response is\n/// self-contained — the client doesn't need a second map.\npub fn provider_catalog() -> Vec<ProviderCatalogEntry> {\n use AuthStyle::*;\n vec![\n ProviderCatalogEntry {\n id: \"openai\".into(),\n name: \"OpenAI\".into(),\n provider: Provider::Openai,\n default_base_url: Provider::Openai.default_base_url().to_string(),\n default_model: \"gpt-4o\".into(),\n console_url: Some(\"https://platform.openai.com/api-keys\".into()),\n key_format: Some(\"sk-\".into()),\n key_format_hint: Some(\"Begins with sk-…\".into()),\n free_tier_note: None,\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"anthropic\".into(),\n name: \"Anthropic\".into(),\n provider: Provider::Anthropic,\n default_base_url: Provider::Anthropic.default_base_url().to_string(),\n default_model: \"claude-sonnet-4-20250514\".into(),\n console_url: Some(\"https://console.anthropic.com/settings/keys\".into()),\n key_format: Some(\"sk-ant-\".into()),\n key_format_hint: Some(\"Begins with sk-ant-…\".into()),\n free_tier_note: Some(\"Free trial credits — no card required.\".into()),\n supports_custom_base_url: false,\n auth_style: XApiKey,\n },\n ProviderCatalogEntry {\n id: \"google\".into(),\n name: \"Google Gemini\".into(),\n provider: Provider::Google,\n default_base_url: Provider::Google.default_base_url().to_string(),\n default_model: \"gemini-2.0-flash\".into(),\n console_url: Some(\"https://aistudio.google.com/apikey\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: Some(\"Free tier: 15 req/min, 1500 req/day.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"kimi\".into(),\n name: \"Kimi (Moonshot AI)\".into(),\n provider: Provider::Kimi,\n default_base_url: Provider::Kimi.default_base_url().to_string(),\n default_model: \"moonshot-v1-8k\".into(),\n console_url: Some(\"https://platform.moonshot.ai/console/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"glm\".into(),\n name: \"GLM (Zhipu)\".into(),\n provider: Provider::Glm,\n default_base_url: Provider::Glm.default_base_url().to_string(),\n default_model: \"glm-4-plus\".into(),\n console_url: Some(\"https://bigmodel.cn/console/overview\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"minimax\".into(),\n name: \"MiniMax\".into(),\n provider: Provider::Minimax,\n default_base_url: Provider::Minimax.default_base_url().to_string(),\n default_model: \"MiniMax-M3\".into(),\n console_url: Some(\"https://api.minimax.io/user-center/basic-information/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"groq\".into(),\n name: \"Groq\".into(),\n provider: Provider::Groq,\n default_base_url: Provider::Groq.default_base_url().to_string(),\n default_model: \"llama-3.1-70b-versatile\".into(),\n console_url: Some(\"https://console.groq.com/keys\".into()),\n key_format: Some(\"gsk_\".into()),\n key_format_hint: Some(\"Begins with gsk_…\".into()),\n free_tier_note: Some(\"Free tier: 30 req/min for most models.\".into()),\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"mistral\".into(),\n name: \"Mistral AI\".into(),\n provider: Provider::Mistral,\n default_base_url: Provider::Mistral.default_base_url().to_string(),\n default_model: \"mistral-large-latest\".into(),\n console_url: Some(\"https://console.mistral.ai/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"deepseek\".into(),\n name: \"DeepSeek\".into(),\n provider: Provider::Deepseek,\n default_base_url: Provider::Deepseek.default_base_url().to_string(),\n default_model: \"deepseek-chat\".into(),\n console_url: Some(\"https://platform.deepseek.com/api_keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"openrouter\".into(),\n name: \"OpenRouter\".into(),\n provider: Provider::Openrouter,\n default_base_url: Provider::Openrouter.default_base_url().to_string(),\n default_model: \"openai/gpt-4o\".into(),\n console_url: Some(\"https://openrouter.ai/keys\".into()),\n key_format: Some(\"sk-or-\".into()),\n key_format_hint: Some(\"Begins with sk-or-…\".into()),\n free_tier_note: Some(\"Free credits on signup; per-model pay-as-you-go.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"nvidia\".into(),\n name: \"NVIDIA NIM\".into(),\n provider: Provider::Nvidia,\n default_base_url: Provider::Nvidia.default_base_url().to_string(),\n default_model: \"stepfun-ai/step-3.7-flash\".into(),\n console_url: Some(\"https://build.nvidia.com/settings/api-keys\".into()),\n key_format: Some(\"nvapi-\".into()),\n key_format_hint: Some(\"Begins with nvapi-…\".into()),\n free_tier_note: Some(\"Free tier: 1000 req/month on most hosted models.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ]\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n/// Probe a provider with an API key and return whether the key is accepted.\n/// Lifted out of `src-tauri/src/lib.rs::test_byok_provider` so the headless\n/// gateway can also serve it (Sprint 2 / audit C-2). Same shape: OpenAI-\n/// compatible providers get a `GET /v1/models` probe first (cheap, no model\n/// id required), Anthropic skips straight to a minimal chat completion.\n///\n/// `base_url` is optional — when `None` we use the provider's default.\n/// Returns a `TestProviderResponse` whose `success` is true iff the key is\n/// accepted. The `message` is the real provider response text on failure\n/// (\"401 Unauthorized\", \"Invalid API key\", …) so the wizard can surface\n/// the actual reason instead of a fake ✓.\npub async fn test_provider(\n provider_id: &str,\n api_key: &str,\n base_url: Option<&str>,\n) -> TestProviderResponse {\n let provider = Provider::from_id(provider_id);\n let url = base_url\n .map(|s| s.to_string())\n .unwrap_or_else(|| provider.default_base_url().to_string());\n let chat_endpoint = url_join(&url, provider.chat_endpoint_path());\n\n let client = match reqwest::Client::builder()\n .user_agent(\"feral/0.1\")\n .timeout(std::time::Duration::from_secs(10))\n .build()\n {\n Ok(c) => c,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"client build: {e}\"),\n models: vec![],\n };\n }\n };\n\n let header_key = provider.api_key_header();\n let header_prefix = provider.api_key_prefix();\n let auth_value = format!(\"{}{}\", header_prefix, api_key);\n\n // Anthropic does NOT publish a `/v1/models` endpoint, so the GET /models\n // probe only applies to OpenAI-compatible providers.\n let probe_status: Option<reqwest::Response> = if !provider.is_openai_compatible() {\n None\n } else {\n let models_endpoint = url_join(&url, \"models\");\n match client\n .get(&models_endpoint)\n .header(header_key, &auth_value)\n .send()\n .await\n {\n Ok(r) => Some(r),\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe /models: {e}\"),\n models: vec![],\n };\n }\n }\n };\n\n if let Some(models_resp) = probe_status {\n let models_status = models_resp.status();\n if models_status.is_success() {\n #[derive(serde::Deserialize)]\n struct ModelList {\n data: Option<Vec<serde_json::Value>>,\n }\n let models: Vec<String> = models_resp\n .json::<ModelList>()\n .await\n .ok()\n .and_then(|r| r.data)\n .map(|items| {\n items\n .iter()\n .filter_map(|v| {\n v.get(\"id\")\n .and_then(|id| id.as_str())\n .map(String::from)\n })\n .collect()\n })\n .unwrap_or_default();\n return TestProviderResponse {\n success: true,\n message: \"Connection successful\".to_string(),\n models,\n };\n }\n if models_status == 401 || models_status == 403 {\n let body = models_resp.text().await.unwrap_or_default();\n return TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", models_status.as_u16(), body),\n models: vec![],\n };\n }\n // Non-auth error — fall through to chat probe.\n }\n\n // /models unavailable (or provider doesn't expose it). Send a minimal\n // non-streaming completion to verify credentials.\n let probe = if provider.is_openai_compatible() {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n \"stream\": false,\n })\n } else {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n })\n };\n let mut chat_req = client\n .post(&chat_endpoint)\n .header(header_key, &auth_value)\n .header(\"Content-Type\", \"application/json\")\n .json(&probe);\n for (name, value) in provider.extra_headers() {\n chat_req = chat_req.header(name, value);\n }\n let chat_resp = match chat_req.send().await {\n Ok(r) => r,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe chat: {e}\"),\n models: vec![],\n };\n }\n };\n\n let chat_status = chat_resp.status();\n let chat_body = chat_resp.text().await.unwrap_or_default();\n\n if chat_status == 401 || chat_status == 403 {\n TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", chat_status.as_u16(), chat_body),\n models: vec![],\n }\n } else {\n TestProviderResponse {\n success: true,\n message: \"Connection successful (auth verified via chat endpoint)\".to_string(),\n models: vec![],\n }\n }\n}\n\n/// Tiny URL join helper — appends `path` to `base` exactly once, dropping\n/// any trailing `/` from `base`. The provider tests above use it to build\n/// `https://api.example.com/v1/models` from `default_base_url()` outputs\n/// that may or may not include a trailing slash.\nfn url_join(base: &str, path: &str) -> String {\n let base = base.trim_end_matches('/');\n let path = path.trim_start_matches('/');\n format!(\"{}/{}\", base, path)\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// True when `err` is the kind of keychain failure that justifies the\n/// file-backed fallback (Linux headless / no D-Bus session / libsecret\n/// missing). Other errors — `NoEntry`, `Invalid`, `TooLong` — should\n/// bubble up: they're a real problem the caller needs to see.\nfn is_keychain_unavailable(err: &keyring::Error) -> bool {\n matches!(\n err,\n keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_)\n )\n}\n\n/// Human label for a keychain-probe error, so callers (e.g. `feral doctor`)\n/// can report the kind without depending on the `keyring` crate themselves.\npub fn keychain_error_kind(err: &keyring::Error) -> &'static str {\n match err {\n keyring::Error::NoStorageAccess(_) => \"NoStorageAccess\",\n keyring::Error::PlatformFailure(_) => \"PlatformFailure\",\n _ => \"other\",\n }\n}\n\n/// Probe the OS keychain with a throwaway credential so `feral doctor`\n/// can surface \"keychain unavailable — byok will fall back to the file\n/// store\" BEFORE the user types their API key and hits the 500. Returns\n/// `Ok(())` when the keychain round-trips (`set` then `delete`), or\n/// `Err` with the keyring error kind so the caller can decide what to\n/// print. We probe under a reserved name (`__feral_doctor_probe__`) so\n/// a real provider's slot is never touched. On non-Linux targets this\n/// is reported as available — Windows Credential Manager and macOS\n/// Keychain are assumed reliable on every SKU Feral ships to.\npub fn keychain_probe() -> Result<(), keyring::Error> {\n const PROBE_USER: &str = \"__feral_doctor_probe__\";\n match keyring::Entry::new(KEYCHAIN_SERVICE, PROBE_USER) {\n Ok(entry) => {\n // round-trip a 1-byte value: set → get → delete. get_password\n // returning NoEntry is fine (the slot was never used); the\n // signal we want is \"set_password didn't reject with\n // NoStorageAccess / PlatformFailure\".\n entry.set_password(\"0\")?;\n let _ = entry.get_password();\n match entry.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e),\n }\n }\n Err(e) => Err(e),\n }\n}\n\n/// True when the file-store fallback has ever been used on this machine.\n/// On non-Linux targets this is always `false` (the file store is\n/// Linux-only); on Linux it inspects `~/.feral/byok.keys` existence.\n/// Surfaced by `feral doctor` to report \"your keys live on disk, not in\n/// the OS keychain\" without forcing the user to `ls ~/.feral/`.\npub fn file_fallback_used() -> bool {\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_store_used()\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n false\n }\n}\n\n/// Store a provider's API key. Tries the OS keychain first; on\n/// `NoStorageAccess` / `PlatformFailure` (the classic Linux-headless case),\n/// falls back transparently to the encrypted file store at\n/// `~/.feral/byok.keys`. The fallback is Linux-only — Windows Credential\n/// Manager and macOS Keychain are reliable on every SKU Feral targets.\n///\n/// On a successful keychain write we also clear any stale file-store entry\n/// so the keychain stays canonical: a key that's migrated off the file\n/// store should not re-appear there on the next boot.\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n match key_entry(provider_id) {\n Ok(entry) => match entry.set_password(key) {\n Ok(()) => {\n #[cfg(target_os = \"linux\")]\n {\n if let Err(e) = crate::byok_file_store::file_clear(provider_id) {\n tracing::warn!(\n provider = %provider_id,\n ?e,\n \"byok: keychain write OK but file-store cleanup failed; \\\n stale entry may persist on disk\"\n );\n }\n }\n Ok(())\n }\n Err(e) if is_keychain_unavailable(&e) => {\n tracing::warn!(\n provider = %provider_id,\n \"byok: OS keychain unavailable ({e}); falling back to encrypted \\\n file store at ~/.feral/byok.keys — headless mode\"\n );\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_set(provider_id, key)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n Err(anyhow::anyhow!(\"keychain unavailable and file-store fallback is Linux-only\"))\n }\n }\n Err(e) => Err(e.into()),\n },\n Err(e) if is_keychain_unavailable(&e) => {\n tracing::warn!(\n provider = %provider_id,\n \"byok: OS keychain unavailable ({e}); falling back to encrypted \\\n file store at ~/.feral/byok.keys — headless mode\"\n );\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_set(provider_id, key)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n Err(anyhow::anyhow!(\"keychain unavailable and file-store fallback is Linux-only\"))\n }\n }\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key. Keychain first; on the same\n/// `NoStorageAccess` / `PlatformFailure` pair, reads from the file store.\n/// Other errors collapse to `None` (matching the old behaviour) — the\n/// caller treats absent key the same as unreadable key.\npub fn byok_get(provider_id: &str) -> Option<String> {\n match key_entry(provider_id) {\n Ok(entry) => match entry.get_password() {\n Ok(k) => Some(k),\n Err(keyring::Error::NoEntry) => None,\n Err(e) if is_keychain_unavailable(&e) => {\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_get(provider_id)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n None\n }\n }\n Err(_) => None,\n },\n Err(e) if is_keychain_unavailable(&e) => {\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_get(provider_id)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n None\n }\n }\n Err(_) => None,\n }\n}\n\n/// Remove a provider's API key from BOTH stores. The keychain and the\n/// file fallback are siblings, not primary/replica: clearing must clear\n/// both so a later write can't resurrect a key from the other side.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n // Try the keychain; missing-entry is success, any other failure bubbles.\n let keychain_result: anyhow::Result<()> = match key_entry(provider_id) {\n Ok(entry) => match entry.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n },\n Err(e) if is_keychain_unavailable(&e) => Ok(()),\n Err(e) => Err(e.into()),\n };\n #[cfg(target_os = \"linux\")]\n {\n // Best-effort: a missing file entry is fine, an I/O error logs\n // but doesn't override the keychain result (which is the source\n // of truth on non-headless systems).\n if let Err(e) = crate::byok_file_store::file_clear(provider_id) {\n tracing::warn!(\n provider = %provider_id,\n ?e,\n \"byok: file-store clear failed; keychain result still returned\"\n );\n }\n }\n keychain_result\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Persist a single provider's configuration: the API key to the OS keychain\n/// (when `config.api_key` is non-empty) and the metadata row to `byok.json`.\n///\n/// Added 2026-07-07 (Phase 0b of the terminal-onboarding slice) so the headless\n/// HTTP gateway exposes the same single-provider write path that\n/// `src-tauri`'s `save_byok_provider` Tauri command uses. Going through this\n/// helper keeps the persistence contract identical across both surfaces:\n///\n/// * Empty `api_key` means \"leave the keychain entry untouched\" — matching\n/// the existing `save` semantics (`byok.rs:486-501`).\n/// * The keychain write happens before the metadata write so a partial\n/// failure leaves byok.json matching what's in the keychain (the metadata\n/// only stores presence + non-secret config; on retry, a re-save with the\n/// same key will converge).\n/// * The returned error preserves the original `keyring::Error` in the\n/// chain so callers (e.g. `api.rs::runtime_byok_save`) can classify it\n/// into typed 4xx vs 5xx responses.\npub fn save_provider(id: &str, config: ProviderConfig) -> anyhow::Result<()> {\n // 1. Keychain write (if the caller provided a non-empty key).\n if !config.api_key.is_empty() {\n byok_set(id, &config.api_key)?;\n }\n // 2. Metadata write. We can't reuse `save(&ByokSettings)` here because\n // that path re-walks every provider — for a single-provider write from\n // a network handler we want the minimum disturbance.\n let mut settings = load_metadata();\n settings.update_provider(id, config);\n write_metadata(&settings)?;\n Ok(())\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n }\n\n /// R4 (single provider record): every catalog row's id must round-trip\n /// through `Provider::from_id` back to the row's own `provider`. This is\n /// the drift check that would have caught the three call sites whose\n /// hand-rolled matches were missing \"nvidia\".\n #[test]\n fn catalog_ids_round_trip_through_from_id() {\n let catalog = provider_catalog();\n assert!(!catalog.is_empty());\n for entry in &catalog {\n assert_eq!(\n Provider::from_id(&entry.id),\n entry.provider,\n \"catalog id {:?} does not resolve to its own provider\",\n entry.id\n );\n assert_ne!(\n entry.provider,\n Provider::Custom,\n \"catalog must not list Custom (id {:?})\",\n entry.id\n );\n }\n assert_eq!(Provider::from_id(\"no-such-provider\"), Provider::Custom);\n }\n\n /// The catalog's `auth_style` is a second representation of\n /// `api_key_header()` — assert they can never disagree.\n #[test]\n fn catalog_auth_style_matches_enum_header() {\n for entry in provider_catalog() {\n let expected = match entry.provider.api_key_header() {\n \"Authorization\" => AuthStyle::Bearer,\n \"x-api-key\" => AuthStyle::XApiKey,\n other => panic!(\"unmapped api_key_header {other:?} for {:?}\", entry.id),\n };\n assert_eq!(\n std::mem::discriminant(&entry.auth_style),\n std::mem::discriminant(&expected),\n \"auth_style drift for {:?}\",\n entry.id\n );\n }\n }\n\n /// `default_base_url` in the catalog row must be the enum's canonical\n /// URL — the row carries a copy for self-containedness, not a fork.\n #[test]\n fn catalog_base_urls_match_enum() {\n for entry in provider_catalog() {\n assert_eq!(\n entry.default_base_url,\n entry.provider.default_base_url(),\n \"default_base_url drift for {:?}\",\n entry.id\n );\n }\n }\n}\n"}, "7f0e6a31ff50234b6094ae9550e571f81302c3ff": {"t": 1785760867.574112, "n": 43111, "h": "61b3079f844ae103", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n\n /// THE canonical provider-id → `Provider` mapping. Every call site that\n /// receives a provider id string (IPC commands, HTTP routes, CLI) must\n /// resolve through here — hand-rolled `match provider_id` copies drifted\n /// (three sites were missing `\"nvidia\"` and fell through to `Custom`,\n /// whose default base URL is a placeholder). Unknown ids map to\n /// `Custom`, which only works with an explicit user-supplied base URL.\n pub fn from_id(id: &str) -> Provider {\n match id {\n \"openai\" => Provider::Openai,\n \"anthropic\" => Provider::Anthropic,\n \"google\" => Provider::Google,\n \"kimi\" => Provider::Kimi,\n \"glm\" => Provider::Glm,\n \"minimax\" => Provider::Minimax,\n \"groq\" => Provider::Groq,\n \"mistral\" => Provider::Mistral,\n \"deepseek\" => Provider::Deepseek,\n \"openrouter\" => Provider::Openrouter,\n \"nvidia\" => Provider::Nvidia,\n _ => Provider::Custom,\n }\n }\n\n /// Protocol family as the sidecar's `set_model` handler understands it\n /// (`FeralAgent/src/sandbox/inference-router.ts` `#providers` map:\n /// unknown families default to OpenAI-compatible). Single source for\n /// the `provider_kind` previously matched inline in `api.rs`.\n pub fn family(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"anthropic\",\n Provider::Google => \"google\",\n _ => \"openai_compatible\",\n }\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers.\n /// Derived from `provider_catalog()` so the wizard catalog and the\n /// settings page can never list different providers.\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n provider_catalog()\n .into_iter()\n .map(|e| (e.id, e.name, e.provider))\n .collect()\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n// ── Public provider catalog (Phase 1, 2026-07-07) ───────────────────────────\n//\n// The canonical source of \"which providers exist and what to show for each\"\n// lives in Rust and is exposed via `GET /runtime/providers/catalog`. The\n// Go TUI wizard and the desktop React OnboardingWizard both consume this\n// catalog instead of maintaining their own parallel slices — that's the\n// drift surface that the terminal-onboarding plan calls out as the\n// root cause of the stale `qwen2.5:7b` placeholder reaching the wizard.\n//\n// Clients pin the catalog version via the `X-Feral-Catalog-Version`\n// response header (see `api.rs::runtime_providers_catalog`). On a\n// version mismatch the client falls back to its bundled slice with a\n// clearly surfaced warning rather than silently dropping providers.\n\n/// Catalog version. Bumped whenever a field is added/removed/renamed\n/// in [`ProviderCatalogEntry`]. Currently `1`.\npub const CATALOG_VERSION: u32 = 1;\n\n/// Auth header style the wizard should emit when constructing the\n/// validation probe (`/providers/test`). Drives the rendered header line\n/// and the key format hint, not the actual HTTP transport (which is\n/// handled by `Provider::api_key_header` + `Provider::api_key_prefix`\n/// once the user submits).\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthStyle {\n /// `Authorization: Bearer <key>` — OpenAI / Groq / Mistral / DeepSeek /\n /// OpenRouter / Kimi / GLM / MiniMax / NVIDIA NIM / Google / Custom.\n Bearer,\n /// `x-api-key: <key>` (plus `anthropic-version: 2023-06-01`) — Anthropic.\n XApiKey,\n}\n\n/// One row of the public provider catalog.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderCatalogEntry {\n pub id: String,\n /// Display name shown in the picker (e.g. \"OpenAI\", \"Anthropic\",\n /// \"NVIDIA NIM\"). Distinct from `id` because `provider_kind` and\n /// display name don't always match (\"minimax\" → \"MiniMax\").\n pub name: String,\n /// The typed `Provider` enum. Duplicated with `id` so callers that\n /// already have a `Provider` value can do an O(1) lookup without\n /// string matching.\n pub provider: Provider,\n /// Default API base URL. Cached here so the catalog row is\n /// self-contained — the gateway doesn't need a second\n /// `Provider::default_base_url()` round-trip to render a card.\n pub default_base_url: String,\n /// Wizard's chosen-by-default model when the user hasn't picked\n /// one yet. The runtime replaces it with whatever the user saved\n /// in `byok.json`.\n pub default_model: String,\n /// Where to manage API keys (e.g. `https://platform.openai.com/api-keys`).\n /// None if the provider doesn't publish a stable console URL.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub console_url: Option<String>,\n /// Recognised key prefix (e.g. `\"sk-\"` for OpenAI, `\"sk-ant-\"` for\n /// Anthropic). Client may render a hint next to the input.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format: Option<String>,\n /// Placeholder text shown in the key-entry input, e.g.\n /// `\"Begins with sk-ant-…\"`. Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format_hint: Option<String>,\n /// Free-tier note shown on the provider card, if any. Examples:\n /// \"Free trial credits — no card required.\"\n /// Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub free_tier_note: Option<String>,\n /// Whether the wizard should accept a custom base URL. False for\n /// hosted-only providers (Anthropic); true for self-host-friendly\n /// ones (OpenAI-compatible providers, where the user might want to\n /// point at a private deployment).\n pub supports_custom_base_url: bool,\n pub auth_style: AuthStyle,\n}\n\n/// Returns the canonical, deduplicated provider catalog.\n///\n/// This is the **single source of truth** for \"which providers can the\n/// user pick in the wizard?\" Adding a new provider = one new entry here plus\n/// a gateway rebuild. TUI + desktop pick it up automatically without code\n/// changes on their side.\n///\n/// `Provider::default_base_url()` remains the canonical per-variant URL\n/// (used by the runtime to issue requests); the catalog row carries a\n/// copy as `default_base_url: String` so the API response is\n/// self-contained — the client doesn't need a second map.\npub fn provider_catalog() -> Vec<ProviderCatalogEntry> {\n use AuthStyle::*;\n vec![\n ProviderCatalogEntry {\n id: \"openai\".into(),\n name: \"OpenAI\".into(),\n provider: Provider::Openai,\n default_base_url: Provider::Openai.default_base_url().to_string(),\n default_model: \"gpt-4o\".into(),\n console_url: Some(\"https://platform.openai.com/api-keys\".into()),\n key_format: Some(\"sk-\".into()),\n key_format_hint: Some(\"Begins with sk-…\".into()),\n free_tier_note: None,\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"anthropic\".into(),\n name: \"Anthropic\".into(),\n provider: Provider::Anthropic,\n default_base_url: Provider::Anthropic.default_base_url().to_string(),\n default_model: \"claude-sonnet-4-20250514\".into(),\n console_url: Some(\"https://console.anthropic.com/settings/keys\".into()),\n key_format: Some(\"sk-ant-\".into()),\n key_format_hint: Some(\"Begins with sk-ant-…\".into()),\n free_tier_note: Some(\"Free trial credits — no card required.\".into()),\n supports_custom_base_url: false,\n auth_style: XApiKey,\n },\n ProviderCatalogEntry {\n id: \"google\".into(),\n name: \"Google Gemini\".into(),\n provider: Provider::Google,\n default_base_url: Provider::Google.default_base_url().to_string(),\n default_model: \"gemini-2.0-flash\".into(),\n console_url: Some(\"https://aistudio.google.com/apikey\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: Some(\"Free tier: 15 req/min, 1500 req/day.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"kimi\".into(),\n name: \"Kimi (Moonshot AI)\".into(),\n provider: Provider::Kimi,\n default_base_url: Provider::Kimi.default_base_url().to_string(),\n default_model: \"moonshot-v1-8k\".into(),\n console_url: Some(\"https://platform.moonshot.ai/console/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"glm\".into(),\n name: \"GLM (Zhipu)\".into(),\n provider: Provider::Glm,\n default_base_url: Provider::Glm.default_base_url().to_string(),\n default_model: \"glm-4-plus\".into(),\n console_url: Some(\"https://bigmodel.cn/console/overview\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"minimax\".into(),\n name: \"MiniMax\".into(),\n provider: Provider::Minimax,\n default_base_url: Provider::Minimax.default_base_url().to_string(),\n default_model: \"MiniMax-M3\".into(),\n console_url: Some(\"https://api.minimax.io/user-center/basic-information/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"groq\".into(),\n name: \"Groq\".into(),\n provider: Provider::Groq,\n default_base_url: Provider::Groq.default_base_url().to_string(),\n default_model: \"llama-3.1-70b-versatile\".into(),\n console_url: Some(\"https://console.groq.com/keys\".into()),\n key_format: Some(\"gsk_\".into()),\n key_format_hint: Some(\"Begins with gsk_…\".into()),\n free_tier_note: Some(\"Free tier: 30 req/min for most models.\".into()),\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"mistral\".into(),\n name: \"Mistral AI\".into(),\n provider: Provider::Mistral,\n default_base_url: Provider::Mistral.default_base_url().to_string(),\n default_model: \"mistral-large-latest\".into(),\n console_url: Some(\"https://console.mistral.ai/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"deepseek\".into(),\n name: \"DeepSeek\".into(),\n provider: Provider::Deepseek,\n default_base_url: Provider::Deepseek.default_base_url().to_string(),\n default_model: \"deepseek-chat\".into(),\n console_url: Some(\"https://platform.deepseek.com/api_keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"openrouter\".into(),\n name: \"OpenRouter\".into(),\n provider: Provider::Openrouter,\n default_base_url: Provider::Openrouter.default_base_url().to_string(),\n default_model: \"openai/gpt-4o\".into(),\n console_url: Some(\"https://openrouter.ai/keys\".into()),\n key_format: Some(\"sk-or-\".into()),\n key_format_hint: Some(\"Begins with sk-or-…\".into()),\n free_tier_note: Some(\"Free credits on signup; per-model pay-as-you-go.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"nvidia\".into(),\n name: \"NVIDIA NIM\".into(),\n provider: Provider::Nvidia,\n default_base_url: Provider::Nvidia.default_base_url().to_string(),\n default_model: \"stepfun-ai/step-3.7-flash\".into(),\n console_url: Some(\"https://build.nvidia.com/settings/api-keys\".into()),\n key_format: Some(\"nvapi-\".into()),\n key_format_hint: Some(\"Begins with nvapi-…\".into()),\n free_tier_note: Some(\"Free tier: 1000 req/month on most hosted models.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ]\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n/// Probe a provider with an API key and return whether the key is accepted.\n/// Lifted out of `src-tauri/src/lib.rs::test_byok_provider` so the headless\n/// gateway can also serve it (Sprint 2 / audit C-2). Same shape: OpenAI-\n/// compatible providers get a `GET /v1/models` probe first (cheap, no model\n/// id required), Anthropic skips straight to a minimal chat completion.\n///\n/// `base_url` is optional — when `None` we use the provider's default.\n/// Returns a `TestProviderResponse` whose `success` is true iff the key is\n/// accepted. The `message` is the real provider response text on failure\n/// (\"401 Unauthorized\", \"Invalid API key\", …) so the wizard can surface\n/// the actual reason instead of a fake ✓.\npub async fn test_provider(\n provider_id: &str,\n api_key: &str,\n base_url: Option<&str>,\n) -> TestProviderResponse {\n let provider = Provider::from_id(provider_id);\n let url = base_url\n .map(|s| s.to_string())\n .unwrap_or_else(|| provider.default_base_url().to_string());\n let chat_endpoint = url_join(&url, provider.chat_endpoint_path());\n\n let client = match reqwest::Client::builder()\n .user_agent(\"feral/0.1\")\n .timeout(std::time::Duration::from_secs(10))\n .build()\n {\n Ok(c) => c,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"client build: {e}\"),\n models: vec![],\n };\n }\n };\n\n let header_key = provider.api_key_header();\n let header_prefix = provider.api_key_prefix();\n let auth_value = format!(\"{}{}\", header_prefix, api_key);\n\n // Anthropic does NOT publish a `/v1/models` endpoint, so the GET /models\n // probe only applies to OpenAI-compatible providers.\n let probe_status: Option<reqwest::Response> = if !provider.is_openai_compatible() {\n None\n } else {\n let models_endpoint = url_join(&url, \"models\");\n match client\n .get(&models_endpoint)\n .header(header_key, &auth_value)\n .send()\n .await\n {\n Ok(r) => Some(r),\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe /models: {e}\"),\n models: vec![],\n };\n }\n }\n };\n\n if let Some(models_resp) = probe_status {\n let models_status = models_resp.status();\n if models_status.is_success() {\n #[derive(serde::Deserialize)]\n struct ModelList {\n data: Option<Vec<serde_json::Value>>,\n }\n let models: Vec<String> = models_resp\n .json::<ModelList>()\n .await\n .ok()\n .and_then(|r| r.data)\n .map(|items| {\n items\n .iter()\n .filter_map(|v| {\n v.get(\"id\")\n .and_then(|id| id.as_str())\n .map(String::from)\n })\n .collect()\n })\n .unwrap_or_default();\n return TestProviderResponse {\n success: true,\n message: \"Connection successful\".to_string(),\n models,\n };\n }\n if models_status == 401 || models_status == 403 {\n let body = models_resp.text().await.unwrap_or_default();\n return TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", models_status.as_u16(), body),\n models: vec![],\n };\n }\n // Non-auth error — fall through to chat probe.\n }\n\n // /models unavailable (or provider doesn't expose it). Send a minimal\n // non-streaming completion to verify credentials.\n let probe = if provider.is_openai_compatible() {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n \"stream\": false,\n })\n } else {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n })\n };\n let mut chat_req = client\n .post(&chat_endpoint)\n .header(header_key, &auth_value)\n .header(\"Content-Type\", \"application/json\")\n .json(&probe);\n for (name, value) in provider.extra_headers() {\n chat_req = chat_req.header(name, value);\n }\n let chat_resp = match chat_req.send().await {\n Ok(r) => r,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe chat: {e}\"),\n models: vec![],\n };\n }\n };\n\n let chat_status = chat_resp.status();\n let chat_body = chat_resp.text().await.unwrap_or_default();\n\n if chat_status == 401 || chat_status == 403 {\n TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", chat_status.as_u16(), chat_body),\n models: vec![],\n }\n } else {\n TestProviderResponse {\n success: true,\n message: \"Connection successful (auth verified via chat endpoint)\".to_string(),\n models: vec![],\n }\n }\n}\n\n/// Tiny URL join helper — appends `path` to `base` exactly once, dropping\n/// any trailing `/` from `base`. The provider tests above use it to build\n/// `https://api.example.com/v1/models` from `default_base_url()` outputs\n/// that may or may not include a trailing slash.\nfn url_join(base: &str, path: &str) -> String {\n let base = base.trim_end_matches('/');\n let path = path.trim_start_matches('/');\n format!(\"{}/{}\", base, path)\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// True when `err` is the kind of keychain failure that justifies the\n/// file-backed fallback (Linux headless / no D-Bus session / libsecret\n/// missing). Other errors — `NoEntry`, `Invalid`, `TooLong` — should\n/// bubble up: they're a real problem the caller needs to see.\nfn is_keychain_unavailable(err: &keyring::Error) -> bool {\n matches!(\n err,\n keyring::Error::NoStorageAccess(_) | keyring::Error::PlatformFailure(_)\n )\n}\n\n/// Store a provider's API key. Tries the OS keychain first; on\n/// `NoStorageAccess` / `PlatformFailure` (the classic Linux-headless case),\n/// falls back transparently to the encrypted file store at\n/// `~/.feral/byok.keys`. The fallback is Linux-only — Windows Credential\n/// Manager and macOS Keychain are reliable on every SKU Feral targets.\n///\n/// On a successful keychain write we also clear any stale file-store entry\n/// so the keychain stays canonical: a key that's migrated off the file\n/// store should not re-appear there on the next boot.\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n match key_entry(provider_id) {\n Ok(entry) => match entry.set_password(key) {\n Ok(()) => {\n #[cfg(target_os = \"linux\")]\n {\n if let Err(e) = crate::byok_file_store::file_clear(provider_id) {\n tracing::warn!(\n provider = %provider_id,\n ?e,\n \"byok: keychain write OK but file-store cleanup failed; \\\n stale entry may persist on disk\"\n );\n }\n }\n Ok(())\n }\n Err(e) if is_keychain_unavailable(&e) => {\n tracing::warn!(\n provider = %provider_id,\n \"byok: OS keychain unavailable ({e}); falling back to encrypted \\\n file store at ~/.feral/byok.keys — headless mode\"\n );\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_set(provider_id, key)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n Err(anyhow::anyhow!(\"keychain unavailable and file-store fallback is Linux-only\"))\n }\n }\n Err(e) => Err(e.into()),\n },\n Err(e) if is_keychain_unavailable(&e) => {\n tracing::warn!(\n provider = %provider_id,\n \"byok: OS keychain unavailable ({e}); falling back to encrypted \\\n file store at ~/.feral/byok.keys — headless mode\"\n );\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_set(provider_id, key)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n Err(anyhow::anyhow!(\"keychain unavailable and file-store fallback is Linux-only\"))\n }\n }\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key. Keychain first; on the same\n/// `NoStorageAccess` / `PlatformFailure` pair, reads from the file store.\n/// Other errors collapse to `None` (matching the old behaviour) — the\n/// caller treats absent key the same as unreadable key.\npub fn byok_get(provider_id: &str) -> Option<String> {\n match key_entry(provider_id) {\n Ok(entry) => match entry.get_password() {\n Ok(k) => Some(k),\n Err(keyring::Error::NoEntry) => None,\n Err(e) if is_keychain_unavailable(&e) => {\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_get(provider_id)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n None\n }\n }\n Err(_) => None,\n },\n Err(e) if is_keychain_unavailable(&e) => {\n #[cfg(target_os = \"linux\")]\n {\n crate::byok_file_store::file_get(provider_id)\n }\n #[cfg(not(target_os = \"linux\"))]\n {\n None\n }\n }\n Err(_) => None,\n }\n}\n\n/// Remove a provider's API key from BOTH stores. The keychain and the\n/// file fallback are siblings, not primary/replica: clearing must clear\n/// both so a later write can't resurrect a key from the other side.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n // Try the keychain; missing-entry is success, any other failure bubbles.\n let keychain_result: anyhow::Result<()> = match key_entry(provider_id) {\n Ok(entry) => match entry.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n },\n Err(e) if is_keychain_unavailable(&e) => Ok(()),\n Err(e) => Err(e.into()),\n };\n #[cfg(target_os = \"linux\")]\n {\n // Best-effort: a missing file entry is fine, an I/O error logs\n // but doesn't override the keychain result (which is the source\n // of truth on non-headless systems).\n if let Err(e) = crate::byok_file_store::file_clear(provider_id) {\n tracing::warn!(\n provider = %provider_id,\n ?e,\n \"byok: file-store clear failed; keychain result still returned\"\n );\n }\n }\n keychain_result\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Persist a single provider's configuration: the API key to the OS keychain\n/// (when `config.api_key` is non-empty) and the metadata row to `byok.json`.\n///\n/// Added 2026-07-07 (Phase 0b of the terminal-onboarding slice) so the headless\n/// HTTP gateway exposes the same single-provider write path that\n/// `src-tauri`'s `save_byok_provider` Tauri command uses. Going through this\n/// helper keeps the persistence contract identical across both surfaces:\n///\n/// * Empty `api_key` means \"leave the keychain entry untouched\" — matching\n/// the existing `save` semantics (`byok.rs:486-501`).\n/// * The keychain write happens before the metadata write so a partial\n/// failure leaves byok.json matching what's in the keychain (the metadata\n/// only stores presence + non-secret config; on retry, a re-save with the\n/// same key will converge).\n/// * The returned error preserves the original `keyring::Error` in the\n/// chain so callers (e.g. `api.rs::runtime_byok_save`) can classify it\n/// into typed 4xx vs 5xx responses.\npub fn save_provider(id: &str, config: ProviderConfig) -> anyhow::Result<()> {\n // 1. Keychain write (if the caller provided a non-empty key).\n if !config.api_key.is_empty() {\n byok_set(id, &config.api_key)?;\n }\n // 2. Metadata write. We can't reuse `save(&ByokSettings)` here because\n // that path re-walks every provider — for a single-provider write from\n // a network handler we want the minimum disturbance.\n let mut settings = load_metadata();\n settings.update_provider(id, config);\n write_metadata(&settings)?;\n Ok(())\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n }\n\n /// R4 (single provider record): every catalog row's id must round-trip\n /// through `Provider::from_id` back to the row's own `provider`. This is\n /// the drift check that would have caught the three call sites whose\n /// hand-rolled matches were missing \"nvidia\".\n #[test]\n fn catalog_ids_round_trip_through_from_id() {\n let catalog = provider_catalog();\n assert!(!catalog.is_empty());\n for entry in &catalog {\n assert_eq!(\n Provider::from_id(&entry.id),\n entry.provider,\n \"catalog id {:?} does not resolve to its own provider\",\n entry.id\n );\n assert_ne!(\n entry.provider,\n Provider::Custom,\n \"catalog must not list Custom (id {:?})\",\n entry.id\n );\n }\n assert_eq!(Provider::from_id(\"no-such-provider\"), Provider::Custom);\n }\n\n /// The catalog's `auth_style` is a second representation of\n /// `api_key_header()` — assert they can never disagree.\n #[test]\n fn catalog_auth_style_matches_enum_header() {\n for entry in provider_catalog() {\n let expected = match entry.provider.api_key_header() {\n \"Authorization\" => AuthStyle::Bearer,\n \"x-api-key\" => AuthStyle::XApiKey,\n other => panic!(\"unmapped api_key_header {other:?} for {:?}\", entry.id),\n };\n assert_eq!(\n std::mem::discriminant(&entry.auth_style),\n std::mem::discriminant(&expected),\n \"auth_style drift for {:?}\",\n entry.id\n );\n }\n }\n\n /// `default_base_url` in the catalog row must be the enum's canonical\n /// URL — the row carries a copy for self-containedness, not a fork.\n #[test]\n fn catalog_base_urls_match_enum() {\n for entry in provider_catalog() {\n assert_eq!(\n entry.default_base_url,\n entry.provider.default_base_url(),\n \"default_base_url drift for {:?}\",\n entry.id\n );\n }\n }\n}\n"}, "26579210ab423547600429072f908f9f9fbf6648": {"t": 1785760869.087928, "n": 38408, "h": "0e349fee32433f1b", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n\n /// THE canonical provider-id → `Provider` mapping. Every call site that\n /// receives a provider id string (IPC commands, HTTP routes, CLI) must\n /// resolve through here — hand-rolled `match provider_id` copies drifted\n /// (three sites were missing `\"nvidia\"` and fell through to `Custom`,\n /// whose default base URL is a placeholder). Unknown ids map to\n /// `Custom`, which only works with an explicit user-supplied base URL.\n pub fn from_id(id: &str) -> Provider {\n match id {\n \"openai\" => Provider::Openai,\n \"anthropic\" => Provider::Anthropic,\n \"google\" => Provider::Google,\n \"kimi\" => Provider::Kimi,\n \"glm\" => Provider::Glm,\n \"minimax\" => Provider::Minimax,\n \"groq\" => Provider::Groq,\n \"mistral\" => Provider::Mistral,\n \"deepseek\" => Provider::Deepseek,\n \"openrouter\" => Provider::Openrouter,\n \"nvidia\" => Provider::Nvidia,\n _ => Provider::Custom,\n }\n }\n\n /// Protocol family as the sidecar's `set_model` handler understands it\n /// (`FeralAgent/src/sandbox/inference-router.ts` `#providers` map:\n /// unknown families default to OpenAI-compatible). Single source for\n /// the `provider_kind` previously matched inline in `api.rs`.\n pub fn family(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"anthropic\",\n Provider::Google => \"google\",\n _ => \"openai_compatible\",\n }\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers.\n /// Derived from `provider_catalog()` so the wizard catalog and the\n /// settings page can never list different providers.\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n provider_catalog()\n .into_iter()\n .map(|e| (e.id, e.name, e.provider))\n .collect()\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n// ── Public provider catalog (Phase 1, 2026-07-07) ───────────────────────────\n//\n// The canonical source of \"which providers exist and what to show for each\"\n// lives in Rust and is exposed via `GET /runtime/providers/catalog`. The\n// Go TUI wizard and the desktop React OnboardingWizard both consume this\n// catalog instead of maintaining their own parallel slices — that's the\n// drift surface that the terminal-onboarding plan calls out as the\n// root cause of the stale `qwen2.5:7b` placeholder reaching the wizard.\n//\n// Clients pin the catalog version via the `X-Feral-Catalog-Version`\n// response header (see `api.rs::runtime_providers_catalog`). On a\n// version mismatch the client falls back to its bundled slice with a\n// clearly surfaced warning rather than silently dropping providers.\n\n/// Catalog version. Bumped whenever a field is added/removed/renamed\n/// in [`ProviderCatalogEntry`]. Currently `1`.\npub const CATALOG_VERSION: u32 = 1;\n\n/// Auth header style the wizard should emit when constructing the\n/// validation probe (`/providers/test`). Drives the rendered header line\n/// and the key format hint, not the actual HTTP transport (which is\n/// handled by `Provider::api_key_header` + `Provider::api_key_prefix`\n/// once the user submits).\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthStyle {\n /// `Authorization: Bearer <key>` — OpenAI / Groq / Mistral / DeepSeek /\n /// OpenRouter / Kimi / GLM / MiniMax / NVIDIA NIM / Google / Custom.\n Bearer,\n /// `x-api-key: <key>` (plus `anthropic-version: 2023-06-01`) — Anthropic.\n XApiKey,\n}\n\n/// One row of the public provider catalog.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderCatalogEntry {\n pub id: String,\n /// Display name shown in the picker (e.g. \"OpenAI\", \"Anthropic\",\n /// \"NVIDIA NIM\"). Distinct from `id` because `provider_kind` and\n /// display name don't always match (\"minimax\" → \"MiniMax\").\n pub name: String,\n /// The typed `Provider` enum. Duplicated with `id` so callers that\n /// already have a `Provider` value can do an O(1) lookup without\n /// string matching.\n pub provider: Provider,\n /// Default API base URL. Cached here so the catalog row is\n /// self-contained — the gateway doesn't need a second\n /// `Provider::default_base_url()` round-trip to render a card.\n pub default_base_url: String,\n /// Wizard's chosen-by-default model when the user hasn't picked\n /// one yet. The runtime replaces it with whatever the user saved\n /// in `byok.json`.\n pub default_model: String,\n /// Where to manage API keys (e.g. `https://platform.openai.com/api-keys`).\n /// None if the provider doesn't publish a stable console URL.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub console_url: Option<String>,\n /// Recognised key prefix (e.g. `\"sk-\"` for OpenAI, `\"sk-ant-\"` for\n /// Anthropic). Client may render a hint next to the input.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format: Option<String>,\n /// Placeholder text shown in the key-entry input, e.g.\n /// `\"Begins with sk-ant-…\"`. Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format_hint: Option<String>,\n /// Free-tier note shown on the provider card, if any. Examples:\n /// \"Free trial credits — no card required.\"\n /// Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub free_tier_note: Option<String>,\n /// Whether the wizard should accept a custom base URL. False for\n /// hosted-only providers (Anthropic); true for self-host-friendly\n /// ones (OpenAI-compatible providers, where the user might want to\n /// point at a private deployment).\n pub supports_custom_base_url: bool,\n pub auth_style: AuthStyle,\n}\n\n/// Returns the canonical, deduplicated provider catalog.\n///\n/// This is the **single source of truth** for \"which providers can the\n/// user pick in the wizard?\" Adding a new provider = one new entry here plus\n/// a gateway rebuild. TUI + desktop pick it up automatically without code\n/// changes on their side.\n///\n/// `Provider::default_base_url()` remains the canonical per-variant URL\n/// (used by the runtime to issue requests); the catalog row carries a\n/// copy as `default_base_url: String` so the API response is\n/// self-contained — the client doesn't need a second map.\npub fn provider_catalog() -> Vec<ProviderCatalogEntry> {\n use AuthStyle::*;\n vec![\n ProviderCatalogEntry {\n id: \"openai\".into(),\n name: \"OpenAI\".into(),\n provider: Provider::Openai,\n default_base_url: Provider::Openai.default_base_url().to_string(),\n default_model: \"gpt-4o\".into(),\n console_url: Some(\"https://platform.openai.com/api-keys\".into()),\n key_format: Some(\"sk-\".into()),\n key_format_hint: Some(\"Begins with sk-…\".into()),\n free_tier_note: None,\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"anthropic\".into(),\n name: \"Anthropic\".into(),\n provider: Provider::Anthropic,\n default_base_url: Provider::Anthropic.default_base_url().to_string(),\n default_model: \"claude-sonnet-4-20250514\".into(),\n console_url: Some(\"https://console.anthropic.com/settings/keys\".into()),\n key_format: Some(\"sk-ant-\".into()),\n key_format_hint: Some(\"Begins with sk-ant-…\".into()),\n free_tier_note: Some(\"Free trial credits — no card required.\".into()),\n supports_custom_base_url: false,\n auth_style: XApiKey,\n },\n ProviderCatalogEntry {\n id: \"google\".into(),\n name: \"Google Gemini\".into(),\n provider: Provider::Google,\n default_base_url: Provider::Google.default_base_url().to_string(),\n default_model: \"gemini-2.0-flash\".into(),\n console_url: Some(\"https://aistudio.google.com/apikey\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: Some(\"Free tier: 15 req/min, 1500 req/day.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"kimi\".into(),\n name: \"Kimi (Moonshot AI)\".into(),\n provider: Provider::Kimi,\n default_base_url: Provider::Kimi.default_base_url().to_string(),\n default_model: \"moonshot-v1-8k\".into(),\n console_url: Some(\"https://platform.moonshot.ai/console/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"glm\".into(),\n name: \"GLM (Zhipu)\".into(),\n provider: Provider::Glm,\n default_base_url: Provider::Glm.default_base_url().to_string(),\n default_model: \"glm-4-plus\".into(),\n console_url: Some(\"https://bigmodel.cn/console/overview\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"minimax\".into(),\n name: \"MiniMax\".into(),\n provider: Provider::Minimax,\n default_base_url: Provider::Minimax.default_base_url().to_string(),\n default_model: \"MiniMax-M3\".into(),\n console_url: Some(\"https://api.minimax.io/user-center/basic-information/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"groq\".into(),\n name: \"Groq\".into(),\n provider: Provider::Groq,\n default_base_url: Provider::Groq.default_base_url().to_string(),\n default_model: \"llama-3.1-70b-versatile\".into(),\n console_url: Some(\"https://console.groq.com/keys\".into()),\n key_format: Some(\"gsk_\".into()),\n key_format_hint: Some(\"Begins with gsk_…\".into()),\n free_tier_note: Some(\"Free tier: 30 req/min for most models.\".into()),\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"mistral\".into(),\n name: \"Mistral AI\".into(),\n provider: Provider::Mistral,\n default_base_url: Provider::Mistral.default_base_url().to_string(),\n default_model: \"mistral-large-latest\".into(),\n console_url: Some(\"https://console.mistral.ai/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"deepseek\".into(),\n name: \"DeepSeek\".into(),\n provider: Provider::Deepseek,\n default_base_url: Provider::Deepseek.default_base_url().to_string(),\n default_model: \"deepseek-chat\".into(),\n console_url: Some(\"https://platform.deepseek.com/api_keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"openrouter\".into(),\n name: \"OpenRouter\".into(),\n provider: Provider::Openrouter,\n default_base_url: Provider::Openrouter.default_base_url().to_string(),\n default_model: \"openai/gpt-4o\".into(),\n console_url: Some(\"https://openrouter.ai/keys\".into()),\n key_format: Some(\"sk-or-\".into()),\n key_format_hint: Some(\"Begins with sk-or-…\".into()),\n free_tier_note: Some(\"Free credits on signup; per-model pay-as-you-go.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"nvidia\".into(),\n name: \"NVIDIA NIM\".into(),\n provider: Provider::Nvidia,\n default_base_url: Provider::Nvidia.default_base_url().to_string(),\n default_model: \"stepfun-ai/step-3.7-flash\".into(),\n console_url: Some(\"https://build.nvidia.com/settings/api-keys\".into()),\n key_format: Some(\"nvapi-\".into()),\n key_format_hint: Some(\"Begins with nvapi-…\".into()),\n free_tier_note: Some(\"Free tier: 1000 req/month on most hosted models.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ]\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n/// Probe a provider with an API key and return whether the key is accepted.\n/// Lifted out of `src-tauri/src/lib.rs::test_byok_provider` so the headless\n/// gateway can also serve it (Sprint 2 / audit C-2). Same shape: OpenAI-\n/// compatible providers get a `GET /v1/models` probe first (cheap, no model\n/// id required), Anthropic skips straight to a minimal chat completion.\n///\n/// `base_url` is optional — when `None` we use the provider's default.\n/// Returns a `TestProviderResponse` whose `success` is true iff the key is\n/// accepted. The `message` is the real provider response text on failure\n/// (\"401 Unauthorized\", \"Invalid API key\", …) so the wizard can surface\n/// the actual reason instead of a fake ✓.\npub async fn test_provider(\n provider_id: &str,\n api_key: &str,\n base_url: Option<&str>,\n) -> TestProviderResponse {\n let provider = Provider::from_id(provider_id);\n let url = base_url\n .map(|s| s.to_string())\n .unwrap_or_else(|| provider.default_base_url().to_string());\n let chat_endpoint = url_join(&url, provider.chat_endpoint_path());\n\n let client = match reqwest::Client::builder()\n .user_agent(\"feral/0.1\")\n .timeout(std::time::Duration::from_secs(10))\n .build()\n {\n Ok(c) => c,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"client build: {e}\"),\n models: vec![],\n };\n }\n };\n\n let header_key = provider.api_key_header();\n let header_prefix = provider.api_key_prefix();\n let auth_value = format!(\"{}{}\", header_prefix, api_key);\n\n // Anthropic does NOT publish a `/v1/models` endpoint, so the GET /models\n // probe only applies to OpenAI-compatible providers.\n let probe_status: Option<reqwest::Response> = if !provider.is_openai_compatible() {\n None\n } else {\n let models_endpoint = url_join(&url, \"models\");\n match client\n .get(&models_endpoint)\n .header(header_key, &auth_value)\n .send()\n .await\n {\n Ok(r) => Some(r),\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe /models: {e}\"),\n models: vec![],\n };\n }\n }\n };\n\n if let Some(models_resp) = probe_status {\n let models_status = models_resp.status();\n if models_status.is_success() {\n #[derive(serde::Deserialize)]\n struct ModelList {\n data: Option<Vec<serde_json::Value>>,\n }\n let models: Vec<String> = models_resp\n .json::<ModelList>()\n .await\n .ok()\n .and_then(|r| r.data)\n .map(|items| {\n items\n .iter()\n .filter_map(|v| {\n v.get(\"id\")\n .and_then(|id| id.as_str())\n .map(String::from)\n })\n .collect()\n })\n .unwrap_or_default();\n return TestProviderResponse {\n success: true,\n message: \"Connection successful\".to_string(),\n models,\n };\n }\n if models_status == 401 || models_status == 403 {\n let body = models_resp.text().await.unwrap_or_default();\n return TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", models_status.as_u16(), body),\n models: vec![],\n };\n }\n // Non-auth error — fall through to chat probe.\n }\n\n // /models unavailable (or provider doesn't expose it). Send a minimal\n // non-streaming completion to verify credentials.\n let probe = if provider.is_openai_compatible() {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n \"stream\": false,\n })\n } else {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n })\n };\n let mut chat_req = client\n .post(&chat_endpoint)\n .header(header_key, &auth_value)\n .header(\"Content-Type\", \"application/json\")\n .json(&probe);\n for (name, value) in provider.extra_headers() {\n chat_req = chat_req.header(name, value);\n }\n let chat_resp = match chat_req.send().await {\n Ok(r) => r,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe chat: {e}\"),\n models: vec![],\n };\n }\n };\n\n let chat_status = chat_resp.status();\n let chat_body = chat_resp.text().await.unwrap_or_default();\n\n if chat_status == 401 || chat_status == 403 {\n TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", chat_status.as_u16(), chat_body),\n models: vec![],\n }\n } else {\n TestProviderResponse {\n success: true,\n message: \"Connection successful (auth verified via chat endpoint)\".to_string(),\n models: vec![],\n }\n }\n}\n\n/// Tiny URL join helper — appends `path` to `base` exactly once, dropping\n/// any trailing `/` from `base`. The provider tests above use it to build\n/// `https://api.example.com/v1/models` from `default_base_url()` outputs\n/// that may or may not include a trailing slash.\nfn url_join(base: &str, path: &str) -> String {\n let base = base.trim_end_matches('/');\n let path = path.trim_start_matches('/');\n format!(\"{}/{}\", base, path)\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// Store a provider's API key in the keychain.\n/// Public API: byok_set(provider_id, api_key)\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n key_entry(provider_id)?.set_password(key)?;\n Ok(())\n}\n\n/// Remove a provider's API key from the keychain. A missing entry is success.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n match key_entry(provider_id)?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key from the keychain, or `None` if absent.\n/// Public API: byok_get(provider_id)\npub fn byok_get(provider_id: &str) -> Option<String> {\n key_entry(provider_id).ok()?.get_password().ok()\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Persist a single provider's configuration: the API key to the OS keychain\n/// (when `config.api_key` is non-empty) and the metadata row to `byok.json`.\n///\n/// Added 2026-07-07 (Phase 0b of the terminal-onboarding slice) so the headless\n/// HTTP gateway exposes the same single-provider write path that\n/// `src-tauri`'s `save_byok_provider` Tauri command uses. Going through this\n/// helper keeps the persistence contract identical across both surfaces:\n///\n/// * Empty `api_key` means \"leave the keychain entry untouched\" — matching\n/// the existing `save` semantics (`byok.rs:486-501`).\n/// * The keychain write happens before the metadata write so a partial\n/// failure leaves byok.json matching what's in the keychain (the metadata\n/// only stores presence + non-secret config; on retry, a re-save with the\n/// same key will converge).\n/// * The returned error preserves the original `keyring::Error` in the\n/// chain so callers (e.g. `api.rs::runtime_byok_save`) can classify it\n/// into typed 4xx vs 5xx responses.\npub fn save_provider(id: &str, config: ProviderConfig) -> anyhow::Result<()> {\n // 1. Keychain write (if the caller provided a non-empty key).\n if !config.api_key.is_empty() {\n byok_set(id, &config.api_key)?;\n }\n // 2. Metadata write. We can't reuse `save(&ByokSettings)` here because\n // that path re-walks every provider — for a single-provider write from\n // a network handler we want the minimum disturbance.\n let mut settings = load_metadata();\n settings.update_provider(id, config);\n write_metadata(&settings)?;\n Ok(())\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n }\n\n /// R4 (single provider record): every catalog row's id must round-trip\n /// through `Provider::from_id` back to the row's own `provider`. This is\n /// the drift check that would have caught the three call sites whose\n /// hand-rolled matches were missing \"nvidia\".\n #[test]\n fn catalog_ids_round_trip_through_from_id() {\n let catalog = provider_catalog();\n assert!(!catalog.is_empty());\n for entry in &catalog {\n assert_eq!(\n Provider::from_id(&entry.id),\n entry.provider,\n \"catalog id {:?} does not resolve to its own provider\",\n entry.id\n );\n assert_ne!(\n entry.provider,\n Provider::Custom,\n \"catalog must not list Custom (id {:?})\",\n entry.id\n );\n }\n assert_eq!(Provider::from_id(\"no-such-provider\"), Provider::Custom);\n }\n\n /// The catalog's `auth_style` is a second representation of\n /// `api_key_header()` — assert they can never disagree.\n #[test]\n fn catalog_auth_style_matches_enum_header() {\n for entry in provider_catalog() {\n let expected = match entry.provider.api_key_header() {\n \"Authorization\" => AuthStyle::Bearer,\n \"x-api-key\" => AuthStyle::XApiKey,\n other => panic!(\"unmapped api_key_header {other:?} for {:?}\", entry.id),\n };\n assert_eq!(\n std::mem::discriminant(&entry.auth_style),\n std::mem::discriminant(&expected),\n \"auth_style drift for {:?}\",\n entry.id\n );\n }\n }\n\n /// `default_base_url` in the catalog row must be the enum's canonical\n /// URL — the row carries a copy for self-containedness, not a fork.\n #[test]\n fn catalog_base_urls_match_enum() {\n for entry in provider_catalog() {\n assert_eq!(\n entry.default_base_url,\n entry.provider.default_base_url(),\n \"default_base_url drift for {:?}\",\n entry.id\n );\n }\n }\n}\n"}, "73fe35cc9b7c3ac7696159e76f72c633d40f8074": {"t": 1785760870.7533207, "n": 38466, "h": "0719a8dac35c105b", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n\n /// THE canonical provider-id → `Provider` mapping. Every call site that\n /// receives a provider id string (IPC commands, HTTP routes, CLI) must\n /// resolve through here — hand-rolled `match provider_id` copies drifted\n /// (three sites were missing `\"nvidia\"` and fell through to `Custom`,\n /// whose default base URL is a placeholder). Unknown ids map to\n /// `Custom`, which only works with an explicit user-supplied base URL.\n pub fn from_id(id: &str) -> Provider {\n match id {\n \"openai\" => Provider::Openai,\n \"anthropic\" => Provider::Anthropic,\n \"google\" => Provider::Google,\n \"kimi\" => Provider::Kimi,\n \"glm\" => Provider::Glm,\n \"minimax\" => Provider::Minimax,\n \"groq\" => Provider::Groq,\n \"mistral\" => Provider::Mistral,\n \"deepseek\" => Provider::Deepseek,\n \"openrouter\" => Provider::Openrouter,\n \"nvidia\" => Provider::Nvidia,\n _ => Provider::Custom,\n }\n }\n\n /// Protocol family as the sidecar's `set_model` handler understands it\n /// (`FeralAgent/src/sandbox/inference-router.ts` `#providers` map:\n /// unknown families default to OpenAI-compatible). Single source for\n /// the `provider_kind` previously matched inline in `api.rs`.\n pub fn family(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"anthropic\",\n Provider::Google => \"google\",\n _ => \"openai_compatible\",\n }\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers.\n /// Derived from `provider_catalog()` so the wizard catalog and the\n /// settings page can never list different providers.\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n provider_catalog()\n .into_iter()\n .map(|e| (e.id, e.name, e.provider))\n .collect()\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n// ── Public provider catalog (Phase 1, 2026-07-07) ───────────────────────────\n//\n// The canonical source of \"which providers exist and what to show for each\"\n// lives in Rust and is exposed via `GET /runtime/providers/catalog`. The\n// Go TUI wizard and the desktop React OnboardingWizard both consume this\n// catalog instead of maintaining their own parallel slices — that's the\n// drift surface that the terminal-onboarding plan calls out as the\n// root cause of the stale `qwen2.5:7b` placeholder reaching the wizard.\n//\n// Clients pin the catalog version via the `X-Feral-Catalog-Version`\n// response header (see `api.rs::runtime_providers_catalog`). On a\n// version mismatch the client falls back to its bundled slice with a\n// clearly surfaced warning rather than silently dropping providers.\n\n/// Catalog version. Bumped whenever a field is added/removed/renamed\n/// in [`ProviderCatalogEntry`]. Currently `1`.\npub const CATALOG_VERSION: u32 = 1;\n\n/// Auth header style the wizard should emit when constructing the\n/// validation probe (`/providers/test`). Drives the rendered header line\n/// and the key format hint, not the actual HTTP transport (which is\n/// handled by `Provider::api_key_header` + `Provider::api_key_prefix`\n/// once the user submits).\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthStyle {\n /// `Authorization: Bearer <key>` — OpenAI / Groq / Mistral / DeepSeek /\n /// OpenRouter / Kimi / GLM / MiniMax / NVIDIA NIM / Google / Custom.\n Bearer,\n /// `x-api-key: <key>` (plus `anthropic-version: 2023-06-01`) — Anthropic.\n XApiKey,\n}\n\n/// One row of the public provider catalog.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderCatalogEntry {\n pub id: String,\n /// Display name shown in the picker (e.g. \"OpenAI\", \"Anthropic\",\n /// \"NVIDIA NIM\"). Distinct from `id` because `provider_kind` and\n /// display name don't always match (\"minimax\" → \"MiniMax\").\n pub name: String,\n /// The typed `Provider` enum. Duplicated with `id` so callers that\n /// already have a `Provider` value can do an O(1) lookup without\n /// string matching.\n pub provider: Provider,\n /// Default API base URL. Cached here so the catalog row is\n /// self-contained — the gateway doesn't need a second\n /// `Provider::default_base_url()` round-trip to render a card.\n pub default_base_url: String,\n /// Wizard's chosen-by-default model when the user hasn't picked\n /// one yet. The runtime replaces it with whatever the user saved\n /// in `byok.json`.\n pub default_model: String,\n /// Where to manage API keys (e.g. `https://platform.openai.com/api-keys`).\n /// None if the provider doesn't publish a stable console URL.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub console_url: Option<String>,\n /// Recognised key prefix (e.g. `\"sk-\"` for OpenAI, `\"sk-ant-\"` for\n /// Anthropic). Client may render a hint next to the input.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format: Option<String>,\n /// Placeholder text shown in the key-entry input, e.g.\n /// `\"Begins with sk-ant-…\"`. Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format_hint: Option<String>,\n /// Free-tier note shown on the provider card, if any. Examples:\n /// \"Free trial credits — no card required.\"\n /// Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub free_tier_note: Option<String>,\n /// Whether the wizard should accept a custom base URL. False for\n /// hosted-only providers (Anthropic); true for self-host-friendly\n /// ones (OpenAI-compatible providers, where the user might want to\n /// point at a private deployment).\n pub supports_custom_base_url: bool,\n pub auth_style: AuthStyle,\n}\n\n/// Returns the canonical, deduplicated provider catalog.\n///\n/// This is the **single source of truth** for \"which providers can the\n/// user pick in the wizard?\" Adding a new provider = one new entry here\n/// + rebuild the gateway. TUI + desktop pick it up automatically without\n/// code changes on their side.\n///\n/// `Provider::default_base_url()` remains the canonical per-variant URL\n/// (used by the runtime to issue requests); the catalog row carries a\n/// copy as `default_base_url: String` so the API response is\n/// self-contained — the client doesn't need a second map.\npub fn provider_catalog() -> Vec<ProviderCatalogEntry> {\n use AuthStyle::*;\n vec![\n ProviderCatalogEntry {\n id: \"openai\".into(),\n name: \"OpenAI\".into(),\n provider: Provider::Openai,\n default_base_url: Provider::Openai.default_base_url().to_string(),\n default_model: \"gpt-4o\".into(),\n console_url: Some(\"https://platform.openai.com/api-keys\".into()),\n key_format: Some(\"sk-\".into()),\n key_format_hint: Some(\"Begins with sk-…\".into()),\n free_tier_note: None,\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"anthropic\".into(),\n name: \"Anthropic\".into(),\n provider: Provider::Anthropic,\n default_base_url: Provider::Anthropic.default_base_url().to_string(),\n default_model: \"claude-sonnet-4-20250514\".into(),\n console_url: Some(\"https://console.anthropic.com/settings/keys\".into()),\n key_format: Some(\"sk-ant-\".into()),\n key_format_hint: Some(\"Begins with sk-ant-…\".into()),\n free_tier_note: Some(\"Free trial credits — no card required.\".into()),\n supports_custom_base_url: false,\n auth_style: XApiKey,\n },\n ProviderCatalogEntry {\n id: \"google\".into(),\n name: \"Google Gemini\".into(),\n provider: Provider::Google,\n default_base_url: Provider::Google.default_base_url().to_string(),\n default_model: \"gemini-2.0-flash\".into(),\n console_url: Some(\"https://aistudio.google.com/apikey\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: Some(\"Free tier: 15 req/min, 1500 req/day.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"kimi\".into(),\n name: \"Kimi (Moonshot AI)\".into(),\n provider: Provider::Kimi,\n default_base_url: Provider::Kimi.default_base_url().to_string(),\n default_model: \"moonshot-v1-8k\".into(),\n console_url: Some(\"https://platform.moonshot.ai/console/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"glm\".into(),\n name: \"GLM (Zhipu)\".into(),\n provider: Provider::Glm,\n default_base_url: Provider::Glm.default_base_url().to_string(),\n default_model: \"glm-4-plus\".into(),\n console_url: Some(\"https://bigmodel.cn/console/overview\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"minimax\".into(),\n name: \"MiniMax\".into(),\n provider: Provider::Minimax,\n default_base_url: Provider::Minimax.default_base_url().to_string(),\n default_model: \"MiniMax-M3\".into(),\n console_url: Some(\"https://api.minimax.io/user-center/basic-information/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"groq\".into(),\n name: \"Groq\".into(),\n provider: Provider::Groq,\n default_base_url: Provider::Groq.default_base_url().to_string(),\n default_model: \"llama-3.1-70b-versatile\".into(),\n console_url: Some(\"https://console.groq.com/keys\".into()),\n key_format: Some(\"gsk_\".into()),\n key_format_hint: Some(\"Begins with gsk_…\".into()),\n free_tier_note: Some(\"Free tier: 30 req/min for most models.\".into()),\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"mistral\".into(),\n name: \"Mistral AI\".into(),\n provider: Provider::Mistral,\n default_base_url: Provider::Mistral.default_base_url().to_string(),\n default_model: \"mistral-large-latest\".into(),\n console_url: Some(\"https://console.mistral.ai/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"deepseek\".into(),\n name: \"DeepSeek\".into(),\n provider: Provider::Deepseek,\n default_base_url: Provider::Deepseek.default_base_url().to_string(),\n default_model: \"deepseek-chat\".into(),\n console_url: Some(\"https://platform.deepseek.com/api_keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"openrouter\".into(),\n name: \"OpenRouter\".into(),\n provider: Provider::Openrouter,\n default_base_url: Provider::Openrouter.default_base_url().to_string(),\n default_model: \"openai/gpt-4o\".into(),\n console_url: Some(\"https://openrouter.ai/keys\".into()),\n key_format: Some(\"sk-or-\".into()),\n key_format_hint: Some(\"Begins with sk-or-…\".into()),\n free_tier_note: Some(\"Free credits on signup; per-model pay-as-you-go.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"nvidia\".into(),\n name: \"NVIDIA NIM\".into(),\n provider: Provider::Nvidia,\n default_base_url: Provider::Nvidia.default_base_url().to_string(),\n default_model: \"stepfun-ai/step-3.7-flash\".into(),\n console_url: Some(\"https://build.nvidia.com/settings/api-keys\".into()),\n key_format: Some(\"nvapi-\".into()),\n key_format_hint: Some(\"Begins with nvapi-…\".into()),\n free_tier_note: Some(\"Free tier: 1000 req/month on most hosted models.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ]\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n/// Probe a provider with an API key and return whether the key is accepted.\n/// Lifted out of `src-tauri/src/lib.rs::test_byok_provider` so the headless\n/// gateway can also serve it (Sprint 2 / audit C-2). Same shape: OpenAI-\n/// compatible providers get a `GET /v1/models` probe first (cheap, no model\n/// id required), Anthropic skips straight to a minimal chat completion.\n///\n/// `base_url` is optional — when `None` we use the provider's default.\n/// Returns a `TestProviderResponse` whose `success` is true iff the key is\n/// accepted. The `message` is the real provider response text on failure\n/// (\"401 Unauthorized\", \"Invalid API key\", …) so the wizard can surface\n/// the actual reason instead of a fake ✓.\npub async fn test_provider(\n provider_id: &str,\n api_key: &str,\n base_url: Option<&str>,\n) -> TestProviderResponse {\n let provider = Provider::from_id(provider_id);\n let url = base_url\n .map(|s| s.to_string())\n .unwrap_or_else(|| provider.default_base_url().to_string());\n let chat_endpoint = url_join(&url, provider.chat_endpoint_path());\n\n let client = match reqwest::Client::builder()\n .user_agent(\"feral/0.1\")\n .timeout(std::time::Duration::from_secs(10))\n .build()\n {\n Ok(c) => c,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"client build: {e}\"),\n models: vec![],\n };\n }\n };\n\n let header_key = provider.api_key_header();\n let header_prefix = provider.api_key_prefix();\n let auth_value = format!(\"{}{}\", header_prefix, api_key);\n\n // Anthropic does NOT publish a `/v1/models` endpoint, so the GET /models\n // probe only applies to OpenAI-compatible providers.\n let probe_status: Option<reqwest::Response> = if !provider.is_openai_compatible() {\n None\n } else {\n let models_endpoint = url_join(&url, \"models\");\n match client\n .get(&models_endpoint)\n .header(header_key, &auth_value)\n .send()\n .await\n {\n Ok(r) => Some(r),\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe /models: {e}\"),\n models: vec![],\n };\n }\n }\n };\n\n if let Some(models_resp) = probe_status {\n let models_status = models_resp.status();\n if models_status.is_success() {\n #[derive(serde::Deserialize)]\n struct ModelList {\n data: Option<Vec<serde_json::Value>>,\n }\n let models: Vec<String> = models_resp\n .json::<ModelList>()\n .await\n .ok()\n .and_then(|r| r.data)\n .map(|items| {\n items\n .iter()\n .filter_map(|v| {\n v.get(\"id\")\n .and_then(|id| id.as_str())\n .map(String::from)\n })\n .collect()\n })\n .unwrap_or_default();\n return TestProviderResponse {\n success: true,\n message: \"Connection successful\".to_string(),\n models,\n };\n }\n if models_status == 401 || models_status == 403 {\n let body = models_resp.text().await.unwrap_or_default();\n return TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", models_status.as_u16(), body),\n models: vec![],\n };\n }\n // Non-auth error — fall through to chat probe.\n }\n\n // /models unavailable (or provider doesn't expose it). Send a minimal\n // non-streaming completion to verify credentials.\n let probe = if provider.is_openai_compatible() {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n \"stream\": false,\n })\n } else {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n })\n };\n let mut chat_req = client\n .post(&chat_endpoint)\n .header(header_key, &auth_value)\n .header(\"Content-Type\", \"application/json\")\n .json(&probe);\n for (name, value) in provider.extra_headers() {\n chat_req = chat_req.header(name, value);\n }\n let chat_resp = match chat_req.send().await {\n Ok(r) => r,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe chat: {e}\"),\n models: vec![],\n };\n }\n };\n\n let chat_status = chat_resp.status();\n let chat_body = chat_resp.text().await.unwrap_or_default();\n\n if chat_status == 401 || chat_status == 403 {\n TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", chat_status.as_u16(), chat_body),\n models: vec![],\n }\n } else {\n TestProviderResponse {\n success: true,\n message: \"Connection successful (auth verified via chat endpoint)\".to_string(),\n models: vec![],\n }\n }\n}\n\n/// Tiny URL join helper — appends `path` to `base` exactly once, dropping\n/// any trailing `/` from `base`. The provider tests above use it to build\n/// `https://api.example.com/v1/models` from `default_base_url()` outputs\n/// that may or may not include a trailing slash.\nfn url_join(base: &str, path: &str) -> String {\n let base = base.trim_end_matches('/');\n let path = path.trim_start_matches('/');\n format!(\"{}/{}\", base, path)\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// Store a provider's API key in the keychain.\n/// Public API: byok_set(provider_id, api_key)\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n key_entry(provider_id)?.set_password(key)?;\n Ok(())\n}\n\n/// Remove a provider's API key from the keychain. A missing entry is success.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n match key_entry(provider_id)?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key from the keychain, or `None` if absent.\n/// Public API: byok_get(provider_id)\npub fn byok_get(provider_id: &str) -> Option<String> {\n match key_entry(provider_id).ok()?.get_password() {\n Ok(k) => Some(k),\n Err(_) => None,\n }\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Persist a single provider's configuration: the API key to the OS keychain\n/// (when `config.api_key` is non-empty) and the metadata row to `byok.json`.\n///\n/// Added 2026-07-07 (Phase 0b of the terminal-onboarding slice) so the headless\n/// HTTP gateway exposes the same single-provider write path that\n/// `src-tauri`'s `save_byok_provider` Tauri command uses. Going through this\n/// helper keeps the persistence contract identical across both surfaces:\n///\n/// * Empty `api_key` means \"leave the keychain entry untouched\" — matching\n/// the existing `save` semantics (`byok.rs:486-501`).\n/// * The keychain write happens before the metadata write so a partial\n/// failure leaves byok.json matching what's in the keychain (the metadata\n/// only stores presence + non-secret config; on retry, a re-save with the\n/// same key will converge).\n/// * The returned error preserves the original `keyring::Error` in the\n/// chain so callers (e.g. `api.rs::runtime_byok_save`) can classify it\n/// into typed 4xx vs 5xx responses.\npub fn save_provider(id: &str, config: ProviderConfig) -> anyhow::Result<()> {\n // 1. Keychain write (if the caller provided a non-empty key).\n if !config.api_key.is_empty() {\n byok_set(id, &config.api_key)?;\n }\n // 2. Metadata write. We can't reuse `save(&ByokSettings)` here because\n // that path re-walks every provider — for a single-provider write from\n // a network handler we want the minimum disturbance.\n let mut settings = load_metadata();\n settings.update_provider(id, config);\n write_metadata(&settings)?;\n Ok(())\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n }\n\n /// R4 (single provider record): every catalog row's id must round-trip\n /// through `Provider::from_id` back to the row's own `provider`. This is\n /// the drift check that would have caught the three call sites whose\n /// hand-rolled matches were missing \"nvidia\".\n #[test]\n fn catalog_ids_round_trip_through_from_id() {\n let catalog = provider_catalog();\n assert!(!catalog.is_empty());\n for entry in &catalog {\n assert_eq!(\n Provider::from_id(&entry.id),\n entry.provider,\n \"catalog id {:?} does not resolve to its own provider\",\n entry.id\n );\n assert_ne!(\n entry.provider,\n Provider::Custom,\n \"catalog must not list Custom (id {:?})\",\n entry.id\n );\n }\n assert_eq!(Provider::from_id(\"no-such-provider\"), Provider::Custom);\n }\n\n /// The catalog's `auth_style` is a second representation of\n /// `api_key_header()` — assert they can never disagree.\n #[test]\n fn catalog_auth_style_matches_enum_header() {\n for entry in provider_catalog() {\n let expected = match entry.provider.api_key_header() {\n \"Authorization\" => AuthStyle::Bearer,\n \"x-api-key\" => AuthStyle::XApiKey,\n other => panic!(\"unmapped api_key_header {other:?} for {:?}\", entry.id),\n };\n assert_eq!(\n std::mem::discriminant(&entry.auth_style),\n std::mem::discriminant(&expected),\n \"auth_style drift for {:?}\",\n entry.id\n );\n }\n }\n\n /// `default_base_url` in the catalog row must be the enum's canonical\n /// URL — the row carries a copy for self-containedness, not a fork.\n #[test]\n fn catalog_base_urls_match_enum() {\n for entry in provider_catalog() {\n assert_eq!(\n entry.default_base_url,\n entry.provider.default_base_url(),\n \"default_base_url drift for {:?}\",\n entry.id\n );\n }\n }\n}\n"}, "05c8f36232e85ae6d51aa8ecbed2d0c7da60caa9": {"t": 1785760872.1488535, "n": 35896, "h": "a925b9d7f59ff70c", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n vec![\n (\"openai\".to_string(), \"OpenAI\".to_string(), Provider::Openai),\n (\"anthropic\".to_string(), \"Anthropic\".to_string(), Provider::Anthropic),\n (\"google\".to_string(), \"Google\".to_string(), Provider::Google),\n (\"kimi\".to_string(), \"Kimi\".to_string(), Provider::Kimi),\n (\"glm\".to_string(), \"GLM\".to_string(), Provider::Glm),\n (\"minimax\".to_string(), \"MiniMax\".to_string(), Provider::Minimax),\n (\"groq\".to_string(), \"Groq\".to_string(), Provider::Groq),\n (\"mistral\".to_string(), \"Mistral\".to_string(), Provider::Mistral),\n (\"deepseek\".to_string(), \"DeepSeek\".to_string(), Provider::Deepseek),\n (\"openrouter\".to_string(), \"OpenRouter\".to_string(), Provider::Openrouter),\n // NVIDIA NIM — OpenAI-compatible hosted models (Llama, Mistral, etc.).\n (\"nvidia\".to_string(), \"NVIDIA NIM\".to_string(), Provider::Nvidia),\n ]\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n// ── Public provider catalog (Phase 1, 2026-07-07) ───────────────────────────\n//\n// The canonical source of \"which providers exist and what to show for each\"\n// lives in Rust and is exposed via `GET /runtime/providers/catalog`. The\n// Go TUI wizard and the desktop React OnboardingWizard both consume this\n// catalog instead of maintaining their own parallel slices — that's the\n// drift surface that the terminal-onboarding plan calls out as the\n// root cause of the stale `qwen2.5:7b` placeholder reaching the wizard.\n//\n// Clients pin the catalog version via the `X-Feral-Catalog-Version`\n// response header (see `api.rs::runtime_providers_catalog`). On a\n// version mismatch the client falls back to its bundled slice with a\n// clearly surfaced warning rather than silently dropping providers.\n\n/// Catalog version. Bumped whenever a field is added/removed/renamed\n/// in [`ProviderCatalogEntry`]. Currently `1`.\npub const CATALOG_VERSION: u32 = 1;\n\n/// Auth header style the wizard should emit when constructing the\n/// validation probe (`/providers/test`). Drives the rendered header line\n/// and the key format hint, not the actual HTTP transport (which is\n/// handled by `Provider::api_key_header` + `Provider::api_key_prefix`\n/// once the user submits).\n#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AuthStyle {\n /// `Authorization: Bearer <key>` — OpenAI / Groq / Mistral / DeepSeek /\n /// OpenRouter / Kimi / GLM / MiniMax / NVIDIA NIM / Google / Custom.\n Bearer,\n /// `x-api-key: <key>` (plus `anthropic-version: 2023-06-01`) — Anthropic.\n XApiKey,\n}\n\n/// One row of the public provider catalog.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderCatalogEntry {\n pub id: String,\n /// Display name shown in the picker (e.g. \"OpenAI\", \"Anthropic\",\n /// \"NVIDIA NIM\"). Distinct from `id` because `provider_kind` and\n /// display name don't always match (\"minimax\" → \"MiniMax\").\n pub name: String,\n /// The typed `Provider` enum. Duplicated with `id` so callers that\n /// already have a `Provider` value can do an O(1) lookup without\n /// string matching.\n pub provider: Provider,\n /// Default API base URL. Cached here so the catalog row is\n /// self-contained — the gateway doesn't need a second\n /// `Provider::default_base_url()` round-trip to render a card.\n pub default_base_url: String,\n /// Wizard's chosen-by-default model when the user hasn't picked\n /// one yet. The runtime replaces it with whatever the user saved\n /// in `byok.json`.\n pub default_model: String,\n /// Where to manage API keys (e.g. `https://platform.openai.com/api-keys`).\n /// None if the provider doesn't publish a stable console URL.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub console_url: Option<String>,\n /// Recognised key prefix (e.g. `\"sk-\"` for OpenAI, `\"sk-ant-\"` for\n /// Anthropic). Client may render a hint next to the input.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format: Option<String>,\n /// Placeholder text shown in the key-entry input, e.g.\n /// `\"Begins with sk-ant-…\"`. Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub key_format_hint: Option<String>,\n /// Free-tier note shown on the provider card, if any. Examples:\n /// \"Free trial credits — no card required.\"\n /// Drives the wizard UI only.\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub free_tier_note: Option<String>,\n /// Whether the wizard should accept a custom base URL. False for\n /// hosted-only providers (Anthropic); true for self-host-friendly\n /// ones (OpenAI-compatible providers, where the user might want to\n /// point at a private deployment).\n pub supports_custom_base_url: bool,\n pub auth_style: AuthStyle,\n}\n\n/// Returns the canonical, deduplicated provider catalog.\n///\n/// This is the **single source of truth** for \"which providers can the\n/// user pick in the wizard?\" Adding a new provider = one new entry here\n/// + rebuild the gateway. TUI + desktop pick it up automatically without\n/// code changes on their side.\n///\n/// `Provider::default_base_url()` remains the canonical per-variant URL\n/// (used by the runtime to issue requests); the catalog row carries a\n/// copy as `default_base_url: String` so the API response is\n/// self-contained — the client doesn't need a second map.\npub fn provider_catalog() -> Vec<ProviderCatalogEntry> {\n use AuthStyle::*;\n vec![\n ProviderCatalogEntry {\n id: \"openai\".into(),\n name: \"OpenAI\".into(),\n provider: Provider::Openai,\n default_base_url: Provider::Openai.default_base_url().to_string(),\n default_model: \"gpt-4o\".into(),\n console_url: Some(\"https://platform.openai.com/api-keys\".into()),\n key_format: Some(\"sk-\".into()),\n key_format_hint: Some(\"Begins with sk-…\".into()),\n free_tier_note: None,\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"anthropic\".into(),\n name: \"Anthropic\".into(),\n provider: Provider::Anthropic,\n default_base_url: Provider::Anthropic.default_base_url().to_string(),\n default_model: \"claude-sonnet-4-20250514\".into(),\n console_url: Some(\"https://console.anthropic.com/settings/keys\".into()),\n key_format: Some(\"sk-ant-\".into()),\n key_format_hint: Some(\"Begins with sk-ant-…\".into()),\n free_tier_note: Some(\"Free trial credits — no card required.\".into()),\n supports_custom_base_url: false,\n auth_style: XApiKey,\n },\n ProviderCatalogEntry {\n id: \"google\".into(),\n name: \"Google Gemini\".into(),\n provider: Provider::Google,\n default_base_url: Provider::Google.default_base_url().to_string(),\n default_model: \"gemini-2.0-flash\".into(),\n console_url: Some(\"https://aistudio.google.com/apikey\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: Some(\"Free tier: 15 req/min, 1500 req/day.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"kimi\".into(),\n name: \"Kimi (Moonshot AI)\".into(),\n provider: Provider::Kimi,\n default_base_url: Provider::Kimi.default_base_url().to_string(),\n default_model: \"moonshot-v1-8k\".into(),\n console_url: Some(\"https://platform.moonshot.ai/console/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"glm\".into(),\n name: \"GLM (Zhipu)\".into(),\n provider: Provider::Glm,\n default_base_url: Provider::Glm.default_base_url().to_string(),\n default_model: \"glm-4-plus\".into(),\n console_url: Some(\"https://bigmodel.cn/console/overview\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"minimax\".into(),\n name: \"MiniMax\".into(),\n provider: Provider::Minimax,\n default_base_url: Provider::Minimax.default_base_url().to_string(),\n default_model: \"MiniMax-M3\".into(),\n console_url: Some(\"https://api.minimax.io/user-center/basic-information/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"groq\".into(),\n name: \"Groq\".into(),\n provider: Provider::Groq,\n default_base_url: Provider::Groq.default_base_url().to_string(),\n default_model: \"llama-3.1-70b-versatile\".into(),\n console_url: Some(\"https://console.groq.com/keys\".into()),\n key_format: Some(\"gsk_\".into()),\n key_format_hint: Some(\"Begins with gsk_…\".into()),\n free_tier_note: Some(\"Free tier: 30 req/min for most models.\".into()),\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"mistral\".into(),\n name: \"Mistral AI\".into(),\n provider: Provider::Mistral,\n default_base_url: Provider::Mistral.default_base_url().to_string(),\n default_model: \"mistral-large-latest\".into(),\n console_url: Some(\"https://console.mistral.ai/api-keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"deepseek\".into(),\n name: \"DeepSeek\".into(),\n provider: Provider::Deepseek,\n default_base_url: Provider::Deepseek.default_base_url().to_string(),\n default_model: \"deepseek-chat\".into(),\n console_url: Some(\"https://platform.deepseek.com/api_keys\".into()),\n key_format: None,\n key_format_hint: None,\n free_tier_note: None,\n supports_custom_base_url: false,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"openrouter\".into(),\n name: \"OpenRouter\".into(),\n provider: Provider::Openrouter,\n default_base_url: Provider::Openrouter.default_base_url().to_string(),\n default_model: \"openai/gpt-4o\".into(),\n console_url: Some(\"https://openrouter.ai/keys\".into()),\n key_format: Some(\"sk-or-\".into()),\n key_format_hint: Some(\"Begins with sk-or-…\".into()),\n free_tier_note: Some(\"Free credits on signup; per-model pay-as-you-go.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ProviderCatalogEntry {\n id: \"nvidia\".into(),\n name: \"NVIDIA NIM\".into(),\n provider: Provider::Nvidia,\n default_base_url: Provider::Nvidia.default_base_url().to_string(),\n default_model: \"stepfun-ai/step-3.7-flash\".into(),\n console_url: Some(\"https://build.nvidia.com/settings/api-keys\".into()),\n key_format: Some(\"nvapi-\".into()),\n key_format_hint: Some(\"Begins with nvapi-…\".into()),\n free_tier_note: Some(\"Free tier: 1000 req/month on most hosted models.\".into()),\n supports_custom_base_url: true,\n auth_style: Bearer,\n },\n ]\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n/// Probe a provider with an API key and return whether the key is accepted.\n/// Lifted out of `src-tauri/src/lib.rs::test_byok_provider` so the headless\n/// gateway can also serve it (Sprint 2 / audit C-2). Same shape: OpenAI-\n/// compatible providers get a `GET /v1/models` probe first (cheap, no model\n/// id required), Anthropic skips straight to a minimal chat completion.\n///\n/// `base_url` is optional — when `None` we use the provider's default.\n/// Returns a `TestProviderResponse` whose `success` is true iff the key is\n/// accepted. The `message` is the real provider response text on failure\n/// (\"401 Unauthorized\", \"Invalid API key\", …) so the wizard can surface\n/// the actual reason instead of a fake ✓.\npub async fn test_provider(\n provider_id: &str,\n api_key: &str,\n base_url: Option<&str>,\n) -> TestProviderResponse {\n let provider = match provider_id {\n \"openai\" => Provider::Openai,\n \"anthropic\" => Provider::Anthropic,\n \"google\" => Provider::Google,\n \"kimi\" => Provider::Kimi,\n \"glm\" => Provider::Glm,\n \"minimax\" => Provider::Minimax,\n \"groq\" => Provider::Groq,\n \"mistral\" => Provider::Mistral,\n \"deepseek\" => Provider::Deepseek,\n \"openrouter\" => Provider::Openrouter,\n _ => Provider::Custom,\n };\n let url = base_url\n .map(|s| s.to_string())\n .unwrap_or_else(|| provider.default_base_url().to_string());\n let chat_endpoint = url_join(&url, provider.chat_endpoint_path());\n\n let client = match reqwest::Client::builder()\n .user_agent(\"feral/0.1\")\n .timeout(std::time::Duration::from_secs(10))\n .build()\n {\n Ok(c) => c,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"client build: {e}\"),\n models: vec![],\n };\n }\n };\n\n let header_key = provider.api_key_header();\n let header_prefix = provider.api_key_prefix();\n let auth_value = format!(\"{}{}\", header_prefix, api_key);\n\n // Anthropic does NOT publish a `/v1/models` endpoint, so the GET /models\n // probe only applies to OpenAI-compatible providers.\n let probe_status: Option<reqwest::Response> = if !provider.is_openai_compatible() {\n None\n } else {\n let models_endpoint = url_join(&url, \"models\");\n match client\n .get(&models_endpoint)\n .header(header_key, &auth_value)\n .send()\n .await\n {\n Ok(r) => Some(r),\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe /models: {e}\"),\n models: vec![],\n };\n }\n }\n };\n\n if let Some(models_resp) = probe_status {\n let models_status = models_resp.status();\n if models_status.is_success() {\n #[derive(serde::Deserialize)]\n struct ModelList {\n data: Option<Vec<serde_json::Value>>,\n }\n let models: Vec<String> = models_resp\n .json::<ModelList>()\n .await\n .ok()\n .and_then(|r| r.data)\n .map(|items| {\n items\n .iter()\n .filter_map(|v| {\n v.get(\"id\")\n .and_then(|id| id.as_str())\n .map(String::from)\n })\n .collect()\n })\n .unwrap_or_default();\n return TestProviderResponse {\n success: true,\n message: \"Connection successful\".to_string(),\n models,\n };\n }\n if models_status == 401 || models_status == 403 {\n let body = models_resp.text().await.unwrap_or_default();\n return TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", models_status.as_u16(), body),\n models: vec![],\n };\n }\n // Non-auth error — fall through to chat probe.\n }\n\n // /models unavailable (or provider doesn't expose it). Send a minimal\n // non-streaming completion to verify credentials.\n let probe = if provider.is_openai_compatible() {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n \"stream\": false,\n })\n } else {\n serde_json::json!({\n \"model\": \"__probe__\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"Hi\" }],\n \"max_tokens\": 1,\n })\n };\n let mut chat_req = client\n .post(&chat_endpoint)\n .header(header_key, &auth_value)\n .header(\"Content-Type\", \"application/json\")\n .json(&probe);\n for (name, value) in provider.extra_headers() {\n chat_req = chat_req.header(name, value);\n }\n let chat_resp = match chat_req.send().await {\n Ok(r) => r,\n Err(e) => {\n return TestProviderResponse {\n success: false,\n message: format!(\"probe chat: {e}\"),\n models: vec![],\n };\n }\n };\n\n let chat_status = chat_resp.status();\n let chat_body = chat_resp.text().await.unwrap_or_default();\n\n if chat_status == 401 || chat_status == 403 {\n TestProviderResponse {\n success: false,\n message: format!(\"Auth failed (HTTP {}): {}\", chat_status.as_u16(), chat_body),\n models: vec![],\n }\n } else {\n TestProviderResponse {\n success: true,\n message: \"Connection successful (auth verified via chat endpoint)\".to_string(),\n models: vec![],\n }\n }\n}\n\n/// Tiny URL join helper — appends `path` to `base` exactly once, dropping\n/// any trailing `/` from `base`. The provider tests above use it to build\n/// `https://api.example.com/v1/models` from `default_base_url()` outputs\n/// that may or may not include a trailing slash.\nfn url_join(base: &str, path: &str) -> String {\n let base = base.trim_end_matches('/');\n let path = path.trim_start_matches('/');\n format!(\"{}/{}\", base, path)\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// Store a provider's API key in the keychain.\n/// Public API: byok_set(provider_id, api_key)\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n key_entry(provider_id)?.set_password(key)?;\n Ok(())\n}\n\n/// Remove a provider's API key from the keychain. A missing entry is success.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n match key_entry(provider_id)?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key from the keychain, or `None` if absent.\n/// Public API: byok_get(provider_id)\npub fn byok_get(provider_id: &str) -> Option<String> {\n match key_entry(provider_id).ok()?.get_password() {\n Ok(k) => Some(k),\n Err(_) => None,\n }\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Persist a single provider's configuration: the API key to the OS keychain\n/// (when `config.api_key` is non-empty) and the metadata row to `byok.json`.\n///\n/// Added 2026-07-07 (Phase 0b of the terminal-onboarding slice) so the headless\n/// HTTP gateway exposes the same single-provider write path that\n/// `src-tauri`'s `save_byok_provider` Tauri command uses. Going through this\n/// helper keeps the persistence contract identical across both surfaces:\n///\n/// * Empty `api_key` means \"leave the keychain entry untouched\" — matching\n/// the existing `save` semantics (`byok.rs:486-501`).\n/// * The keychain write happens before the metadata write so a partial\n/// failure leaves byok.json matching what's in the keychain (the metadata\n/// only stores presence + non-secret config; on retry, a re-save with the\n/// same key will converge).\n/// * The returned error preserves the original `keyring::Error` in the\n/// chain so callers (e.g. `api.rs::runtime_byok_save`) can classify it\n/// into typed 4xx vs 5xx responses.\npub fn save_provider(id: &str, config: ProviderConfig) -> anyhow::Result<()> {\n // 1. Keychain write (if the caller provided a non-empty key).\n if !config.api_key.is_empty() {\n byok_set(id, &config.api_key)?;\n }\n // 2. Metadata write. We can't reuse `save(&ByokSettings)` here because\n // that path re-walks every provider — for a single-provider write from\n // a network handler we want the minimum disturbance.\n let mut settings = load_metadata();\n settings.update_provider(id, config);\n write_metadata(&settings)?;\n Ok(())\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n }\n}\n"}, "2899edc0e2e0814f37d5a06db12c1007ae16a992": {"t": 1785760873.7838614, "n": 16935, "h": "9035ebf123a4509d", "c": "//! BYOK (Bring Your Own Key) — Cloud AI provider integration.\n//! Stores API keys and provides a unified proxy for cloud AI models.\n\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\n/// Known cloud AI providers\n#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, specta::Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum Provider {\n Openai,\n Anthropic,\n Google,\n Kimi,\n Glm,\n Minimax,\n Groq,\n Mistral,\n Deepseek,\n Openrouter,\n Nvidia,\n Custom,\n}\n\nimpl Provider {\n /// Returns the default base URL for each provider's API\n pub fn default_base_url(&self) -> &'static str {\n match self {\n Provider::Openai => \"https://api.openai.com/v1\",\n Provider::Anthropic => \"https://api.anthropic.com/v1\",\n // Google Gemini's OpenAI-compatible surface lives under\n // /v1beta/openai/. Without the `/openai/` segment, requests to\n // `/chat/completions` hit the native Gemini endpoint and 404 —\n // /v1beta/openai/chat/completions is the path the docs publish.\n Provider::Google => \"https://generativelanguage.googleapis.com/v1beta/openai\",\n Provider::Kimi => \"https://api.kimi.com/coding/v1\",\n Provider::Glm => \"https://api.z.ai/api/coding/paas/v4\",\n Provider::Minimax => \"https://api.minimax.io/v1\",\n Provider::Groq => \"https://api.groq.com/openai/v1\",\n Provider::Mistral => \"https://api.mistral.ai/v1\",\n Provider::Deepseek => \"https://api.deepseek.com/v1\",\n Provider::Openrouter => \"https://openrouter.ai/api/v1\",\n // NVIDIA NIM — OpenAI-compatible chat completions API.\n // Trailing /v1 because the API path is /v1/chat/completions.\n Provider::Nvidia => \"https://integrate.api.nvidia.com/v1\",\n Provider::Custom => \"https://api.custom.com/v1\",\n }\n }\n\n /// Returns the API key header name for this provider\n pub fn api_key_header(&self) -> &'static str {\n match self {\n Provider::Openai | Provider::Groq | Provider::Mistral | Provider::Deepseek |\n Provider::Openrouter | Provider::Kimi | Provider::Glm | Provider::Minimax |\n Provider::Nvidia => \"Authorization\",\n Provider::Anthropic => \"x-api-key\",\n Provider::Google => \"Authorization\",\n Provider::Custom => \"Authorization\",\n }\n }\n\n /// Returns the API key prefix format (e.g., \"Bearer \")\n pub fn api_key_prefix(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"\",\n Provider::Google => \"Bearer \",\n Provider::Custom => \"\",\n _ => \"Bearer \",\n }\n }\n\n /// Returns the chat completions endpoint path.\n /// Kept for the in-progress per-provider endpoint routing; not wired yet.\n #[allow(dead_code)]\n pub fn chat_endpoint(&self) -> &'static str {\n \"/chat/completions\"\n }\n\n /// Returns the path (relative to `default_base_url()`) used for a chat\n /// completion call. Anthropic uses `/v1/messages` — its protocol is\n /// similar to OpenAI but the endpoint is different, so the legacy\n /// `url_join(base_url, \"chat/completions\")` produced a 404.\n pub fn chat_endpoint_path(&self) -> &'static str {\n match self {\n Provider::Anthropic => \"messages\",\n _ => \"chat/completions\",\n }\n }\n\n /// Extra HTTP headers required by the provider. Anthropic requires\n /// `anthropic-version` on every Messages API request; the others use\n /// OpenAI's standard `Authorization: Bearer …` only.\n pub fn extra_headers(&self) -> Vec<(&'static str, &'static str)> {\n match self {\n Provider::Anthropic => vec![(\"anthropic-version\", \"2023-06-01\")],\n _ => Vec::new(),\n }\n }\n\n /// Returns whether this provider uses OpenAI-compatible format.\n /// Kept for the in-progress per-provider request shaping; not wired yet.\n #[allow(dead_code)]\n pub fn is_openai_compatible(&self) -> bool {\n !matches!(self, Provider::Anthropic)\n }\n}\n\nimpl std::fmt::Display for Provider {\n fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Provider::Openai => write!(f, \"OpenAI\"),\n Provider::Anthropic => write!(f, \"Anthropic\"),\n Provider::Google => write!(f, \"Google\"),\n Provider::Kimi => write!(f, \"Kimi\"),\n Provider::Glm => write!(f, \"GLM\"),\n Provider::Minimax => write!(f, \"MiniMax\"),\n Provider::Groq => write!(f, \"Groq\"),\n Provider::Mistral => write!(f, \"Mistral\"),\n Provider::Deepseek => write!(f, \"DeepSeek\"),\n Provider::Openrouter => write!(f, \"OpenRouter\"),\n Provider::Nvidia => write!(f, \"NVIDIA NIM\"),\n Provider::Custom => write!(f, \"Custom\"),\n }\n }\n}\n\n/// Per-provider API key and configuration.\n///\n/// V6: `api_key` is NEVER serialized to disk. It lives only in memory (loaded\n/// from the OS keychain on `load`, supplied by the UI on `save`). The\n/// `skip_serializing` attribute keeps it out of `byok.json`; `default` lets it\n/// still deserialize from a legacy plaintext file so existing keys can be\n/// migrated into the keychain on first load.\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\n#[derive(Default)]\npub struct ProviderConfig {\n pub enabled: bool,\n #[serde(default, skip_serializing)]\n pub api_key: String,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n\n/// BYOK settings — stored in settings.json\n#[derive(Debug, Clone, Serialize, Deserialize, Default)]\npub struct ByokSettings {\n pub providers: HashMap<String, ProviderConfig>,\n}\n\nimpl ByokSettings {\n /// Get a list of all supported providers with their current config\n pub fn get_all_providers(&self) -> Vec<ProviderInfo> {\n let defaults = Self::default_provider_configs();\n defaults.into_iter().map(|(id, name, provider)| {\n let config = self.providers.get(&id).cloned().unwrap_or_default();\n ProviderInfo {\n id: id.clone(),\n name,\n provider: provider.clone(),\n enabled: config.enabled,\n has_api_key: !config.api_key.is_empty(),\n base_url: config.base_url.or(Some(provider.default_base_url().to_string())),\n default_model: config.default_model,\n }\n }).collect()\n }\n\n /// Get default configurations for all known providers\n fn default_provider_configs() -> Vec<(String, String, Provider)> {\n vec![\n (\"openai\".to_string(), \"OpenAI\".to_string(), Provider::Openai),\n (\"anthropic\".to_string(), \"Anthropic\".to_string(), Provider::Anthropic),\n (\"google\".to_string(), \"Google\".to_string(), Provider::Google),\n (\"kimi\".to_string(), \"Kimi\".to_string(), Provider::Kimi),\n (\"glm\".to_string(), \"GLM\".to_string(), Provider::Glm),\n (\"minimax\".to_string(), \"MiniMax\".to_string(), Provider::Minimax),\n (\"groq\".to_string(), \"Groq\".to_string(), Provider::Groq),\n (\"mistral\".to_string(), \"Mistral\".to_string(), Provider::Mistral),\n (\"deepseek\".to_string(), \"DeepSeek\".to_string(), Provider::Deepseek),\n (\"openrouter\".to_string(), \"OpenRouter\".to_string(), Provider::Openrouter),\n // NVIDIA NIM — OpenAI-compatible hosted models (Llama, Mistral, etc.).\n (\"nvidia\".to_string(), \"NVIDIA NIM\".to_string(), Provider::Nvidia),\n ]\n }\n\n /// Update config for a specific provider\n pub fn update_provider(&mut self, id: &str, config: ProviderConfig) {\n self.providers.insert(id.to_string(), config);\n }\n\n /// Get config for a specific provider\n pub fn get_provider(&self, id: &str) -> Option<&ProviderConfig> {\n self.providers.get(id)\n }\n}\n\n/// Provider info for the frontend\n#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]\npub struct ProviderInfo {\n pub id: String,\n pub name: String,\n pub provider: Provider,\n pub enabled: bool,\n pub has_api_key: bool,\n pub base_url: Option<String>,\n pub default_model: Option<String>,\n}\n\n/// Request to test a provider connection.\n/// Part of the in-progress \"test connection\" command; not wired to a handler yet.\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\npub struct TestProviderRequest {\n pub provider_id: String,\n pub api_key: String,\n pub base_url: Option<String>,\n}\n\n/// Response from testing a provider\n#[derive(Debug, Serialize, specta::Type)]\npub struct TestProviderResponse {\n pub success: bool,\n pub message: String,\n pub models: Vec<String>,\n}\n\n// ── OS keychain storage for API keys (V6) ───────────────────────────────────\n//\n// API keys are stored in the platform secret store (Windows Credential\n// Manager / macOS Keychain / Linux Secret Service) under one service name,\n// keyed by provider id. byok.json keeps only non-secret metadata. This means\n// a copy of the config file — backed up, synced, or leaked — never carries\n// the keys.\n\n/// Keychain service name. Stable across launches so keys persist; namespaced\n/// to Feral so it never collides with other apps' credentials.\nconst KEYCHAIN_SERVICE: &str = \"ai.bloom.feral.byok\";\n\n/// Create a keyring entry for the given provider id.\nfn key_entry(provider_id: &str) -> Result<keyring::Entry, keyring::Error> {\n keyring::Entry::new(KEYCHAIN_SERVICE, provider_id)\n}\n\n/// Store a provider's API key in the keychain.\n/// Public API: byok_set(provider_id, api_key)\npub fn byok_set(provider_id: &str, key: &str) -> anyhow::Result<()> {\n key_entry(provider_id)?.set_password(key)?;\n Ok(())\n}\n\n/// Remove a provider's API key from the keychain. A missing entry is success.\nfn clear_key(provider_id: &str) -> anyhow::Result<()> {\n match key_entry(provider_id)?.delete_credential() {\n Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),\n Err(e) => Err(e.into()),\n }\n}\n\n/// Read a provider's API key from the keychain, or `None` if absent.\n/// Public API: byok_get(provider_id)\npub fn byok_get(provider_id: &str) -> Option<String> {\n match key_entry(provider_id).ok()?.get_password() {\n Ok(k) => Some(k),\n Err(_) => None,\n }\n}\n\n/// Load BYOK settings: non-secret metadata from `byok.json`, API keys from the\n/// OS keychain. Migrates any plaintext keys found in a legacy `byok.json` into\n/// the keychain, then deletes the legacy file.\npub fn load(_settings: &crate::settings::Settings) -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n let mut s = match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n };\n\n let mut migrated_any = false;\n for (id, cfg) in s.providers.iter_mut() {\n if !cfg.api_key.is_empty() {\n // Legacy plaintext key present in the file → migrate to keychain.\n if let Err(e) = byok_set(id, &cfg.api_key) {\n tracing::warn!(provider = %id, ?e, \"byok: failed to migrate key to keychain\");\n } else {\n migrated_any = true;\n }\n // Keep it in memory for this load; the rewrite below drops it from disk.\n } else if let Some(k) = byok_get(id) {\n cfg.api_key = k;\n }\n }\n\n if migrated_any {\n // After migration, remove the legacy plaintext file entirely.\n // The metadata is regenerated on-demand when save() is called.\n if let Err(e) = std::fs::remove_file(&path) {\n tracing::warn!(?e, \"byok: failed to delete legacy byok.json after migration\");\n } else {\n tracing::info!(\"byok: migrated plaintext API key(s) into the OS keychain and removed byok.json\");\n }\n }\n\n s\n}\n\n/// Persist BYOK settings: API keys to the keychain, metadata to `byok.json`.\n///\n/// An empty `api_key` means \"leave the stored key untouched\" — the UI sends a\n/// blank field to mean \"unchanged\" (its placeholder reads \"Key saved — enter a\n/// new key to update\"). `save` therefore never deletes on empty; explicit\n/// removal goes through [`remove_provider`]. This makes routine edits (toggling\n/// `enabled`, changing the default model) safe without re-typing the key.\npub fn save(settings: &ByokSettings) -> anyhow::Result<()> {\n crate::paths::ensure_dirs()?;\n for (id, cfg) in &settings.providers {\n if !cfg.api_key.is_empty() {\n byok_set(id, &cfg.api_key)?;\n }\n }\n write_metadata(settings)\n}\n\n/// Explicitly remove a provider's API key and disable it. The metadata row is\n/// kept (so the provider still appears in the UI) but its key is purged from\n/// the keychain.\npub fn remove_provider(id: &str) -> anyhow::Result<()> {\n clear_key(id)?;\n let mut settings = load_metadata();\n if let Some(cfg) = settings.providers.get_mut(id) {\n cfg.enabled = false;\n cfg.api_key = String::new();\n }\n write_metadata(&settings)\n}\n\n/// Read only the on-disk metadata (no keychain access). Used by mutation paths\n/// that must not re-trigger migration or key population.\nfn load_metadata() -> ByokSettings {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n match std::fs::read(&path) {\n Ok(bytes) => serde_json::from_slice::<ByokSettings>(&bytes).unwrap_or_default(),\n Err(_) => ByokSettings::default(),\n }\n}\n\n/// Write only the non-secret metadata to disk (api_key is skip_serializing).\nfn write_metadata(settings: &ByokSettings) -> anyhow::Result<()> {\n let path = crate::paths::feral_dir().join(\"byok.json\");\n std::fs::write(path, serde_json::to_vec_pretty(settings)?)?;\n Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn google_default_base_url_uses_openai_compat_path() {\n // The OpenAI-compatible surface for Gemini is published at\n // /v1beta/openai/. The previous default `/v1beta` was the native\n // Gemini endpoint and 404'd on every chat-completions call.\n assert_eq!(\n Provider::Google.default_base_url(),\n \"https://generativelanguage.googleapis.com/v1beta/openai\"\n );\n }\n\n #[test]\n fn anthropic_uses_messages_endpoint_and_x_api_key() {\n // Anthropic's protocol is similar to OpenAI but the path is `/v1/messages`\n // (not `/v1/chat/completions`), the auth header is `x-api-key` without a\n // `Bearer ` prefix, and `anthropic-version` is mandatory.\n assert_eq!(Provider::Anthropic.chat_endpoint_path(), \"messages\");\n assert_eq!(Provider::Anthropic.api_key_header(), \"x-api-key\");\n assert_eq!(Provider::Anthropic.api_key_prefix(), \"\");\n assert_eq!(\n Provider::Anthropic.extra_headers(),\n vec![(\"anthropic-version\", \"2023-06-01\")]\n );\n assert!(!Provider::Anthropic.is_openai_compatible());\n }\n\n #[test]\n fn openai_uses_chat_completions_and_bearer() {\n // OpenAI and the OpenAI-compatible providers route through\n // `/v1/chat/completions` with `Authorization: Bearer …` and no extra\n // headers.\n for p in [\n Provider::Openai,\n Provider::Kimi,\n Provider::Glm,\n Provider::Minimax,\n Provider::Groq,\n Provider::Mistral,\n Provider::Deepseek,\n Provider::Openrouter,\n Provider::Nvidia,\n ] {\n assert_eq!(p.chat_endpoint_path(), \"chat/completions\", \"endpoint for {p:?}\");\n assert_eq!(p.api_key_header(), \"Authorization\", \"header for {p:?}\");\n assert_eq!(p.api_key_prefix(), \"Bearer \", \"prefix for {p:?}\");\n assert!(p.extra_headers().is_empty(), \"no extras for {p:?}\");\n assert!(p.is_openai_compatible(), \"{p:?} should be OpenAI-compat\");\n }\n // Custom is OpenAI-shaped but sends the key verbatim (empty prefix) so\n // users can paste a full `Bearer …`/`Basic …`/raw token themselves.\n assert_eq!(Provider::Custom.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Custom.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Custom.api_key_prefix(), \"\");\n assert!(Provider::Custom.is_openai_compatible());\n }\n\n #[test]\n fn google_uses_openai_compat_path() {\n // Gemini follows the OpenAI shape (after the /openai/ fix above) —\n // /chat/completions, Bearer, no extras.\n assert_eq!(Provider::Google.chat_endpoint_path(), \"chat/completions\");\n assert_eq!(Provider::Google.api_key_header(), \"Authorization\");\n assert_eq!(Provider::Google.api_key_prefix(), \"Bearer \");\n assert!(Provider::Google.extra_headers().is_empty());\n assert!(Provider::Google.is_openai_compatible());\n }\n}\n"}}}} |