diff --git a/apps/desktop-tauri/src-tauri/src/commands/mod.rs b/apps/desktop-tauri/src-tauri/src/commands/mod.rs index 06721a6e8e..d3aa838481 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/mod.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/mod.rs @@ -75,6 +75,8 @@ pub use surface::*; pub use system::*; pub(crate) use usage_items::*; +#[cfg(test)] +mod session_cookie_scope_tests; #[cfg(test)] mod tests; diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 4028a688e9..f2a3852773 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -226,6 +226,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::Replicate, "helmcode" => ProviderId::Helmcode, "typesafe" => ProviderId::TypeSafe, + "hyper" => ProviderId::Hyper, _ => return None, }) } @@ -334,6 +335,7 @@ fn workspace_provider(provider_id: &str) -> Option { "xai" => ProviderId::Xai, "v0" => ProviderId::V0, "helmcode" => ProviderId::Helmcode, + "gitkraken" => ProviderId::GitKraken, _ => return None, }) } @@ -389,7 +391,7 @@ fn litellm_workspace_change_allowed( mod tests { use codexbar::core::ProviderId; - use super::{litellm_workspace_change_allowed, workspace_provider}; + use super::{gateway_provider, litellm_workspace_change_allowed, workspace_provider}; #[test] fn maps_opencode_go_workspace_provider() { @@ -399,6 +401,18 @@ mod tests { ); } + #[test] + fn maps_gitkraken_organization_provider() { + assert_eq!(workspace_provider("gitkraken"), Some(ProviderId::GitKraken)); + } + + #[test] + fn gateway_provider_exposes_wayfinder_and_bifrost_only() { + assert_eq!(gateway_provider("wayfinder"), Some(ProviderId::Wayfinder)); + assert_eq!(gateway_provider("bifrost"), Some(ProviderId::Bifrost)); + assert_eq!(gateway_provider("codex"), None); + } + #[test] fn litellm_endpoint_change_requires_reentering_saved_key() { assert!( @@ -450,7 +464,18 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result, } fn gateway_provider(provider_id: &str) -> Option { - (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) + match provider_id { + "wayfinder" => Some(codexbar::core::ProviderId::Wayfinder), + "bifrost" => Some(codexbar::core::ProviderId::Bifrost), + _ => None, + } +} + +#[tauri::command] +pub fn get_provider_gateway_url(provider_id: String) -> Result { + let id = gateway_provider(&provider_id) + .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; + Ok(Settings::load().gateway_url(id).to_string()) } #[tauri::command] @@ -458,8 +483,17 @@ pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Res let id = gateway_provider(&provider_id) .ok_or_else(|| format!("Provider '{provider_id}' does not expose a gateway URL"))?; let gateway_url = gateway_url.trim(); - codexbar::providers::wayfinder::parse_gateway_url(gateway_url) - .map_err(|error| error.to_string())?; + match id { + codexbar::core::ProviderId::Wayfinder => { + codexbar::providers::wayfinder::parse_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + codexbar::core::ProviderId::Bifrost => { + codexbar::providers::bifrost::validate_gateway_url(gateway_url) + .map_err(|error| error.to_string())?; + } + _ => unreachable!("gateway_provider only returns gateway providers"), + } let mut settings = Settings::load(); settings.set_gateway_url(id, gateway_url.to_string()); @@ -786,6 +820,31 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option( + lang, + "auto", + "Prefer a signed-in Hyper browser session, then fall back to an API key.", + "", + None, + ), + cookie_option( + lang, + "manual", + "", + "Paste a Cookie header from hyper.charm.land.", + None, + ), + cookie_option( + lang, + "off", + "", + "", + Some("Use only the configured API key."), + ), + ], _ => Vec::new(), } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..8b4794b1e6 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -113,6 +113,24 @@ pub(crate) fn build_fetch_context( (SourceMode::Web, stored_cookie.clone(), false) } _ if active_token_env.is_some() => (SourceMode::OAuth, None, false), + // Charm Hyper: the cookie source only picks the session, and + // the usage source keeps routing. Off and an empty Manual + // source never import a browser session, while Auto keeps its + // API-key fallback. + "off" | "manual" if provider.cookie_source_scopes_session_only() => { + let cookie_header = if cookie_source == "manual" { + active_token_cookie.or(stored_cookie) + } else { + None + }; + let source_mode = if provider.available_sources().contains(&usage_source) { + usage_source + } else { + SourceMode::Auto + }; + let cookie_missing = cookie_header.is_none(); + (source_mode, cookie_header, cookie_missing) + } "off" if provider_uses_oauth_without_cookies(id, usage_source) => { (SourceMode::OAuth, None, false) } diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs new file mode 100644 index 0000000000..d5cf6229d1 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -0,0 +1,120 @@ +//! Fetch-context routing for providers whose cookie source only scopes the +//! browser session (Charm Hyper): the usage source keeps routing, and off or +//! an empty manual source never hands the provider a browser session. + +use std::collections::HashMap; + +use codexbar::core::{FetchContext, ProviderId, SourceMode}; +use codexbar::settings::{ApiKeys, Language, ManualCookies, Settings}; + +fn hyper_context( + cookie_source: Option<&str>, + usage_source: &str, + cookie: Option<&str>, +) -> FetchContext { + let mut settings = Settings::default(); + if let Some(cookie_source) = cookie_source { + settings.set_cookie_source(ProviderId::Hyper, cookie_source); + } + settings.set_usage_source(ProviderId::Hyper, usage_source); + let mut cookies = ManualCookies::default(); + if let Some(cookie) = cookie { + cookies.set("hyper", cookie); + } + let mut api_keys = ApiKeys::default(); + api_keys.set("hyper", "fixture-key", None); + super::build_fetch_context( + ProviderId::Hyper, + &settings, + &cookies, + &api_keys, + &HashMap::new(), + ) +} + +#[test] +fn hyper_defaults_to_automatic_session_with_the_provider_owning_the_browser_read() { + // No stored cookie: the shell must not read the browser itself, so the + // API source never touches cookies and Auto/Web import inside the provider. + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(None, usage_source, None); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(!ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); + } +} + +#[test] +fn hyper_manual_cookie_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("manual"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert_eq!(ctx.manual_cookie_header.as_deref(), Some("session=fixture")); + assert!(!ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_empty_manual_source_falls_back_to_the_key_without_a_browser_session() { + let ctx = hyper_context(Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Auto); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + assert_eq!(ctx.api_key.as_deref(), Some("fixture-key")); +} + +#[test] +fn hyper_cookie_off_ignores_a_stored_cookie_and_keeps_the_usage_source() { + for (usage_source, expected) in [ + ("auto", SourceMode::Auto), + ("web", SourceMode::Web), + ("oauth", SourceMode::OAuth), + ] { + let ctx = hyper_context(Some("off"), usage_source, Some("session=fixture")); + assert_eq!(ctx.source_mode, expected, "{usage_source}"); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_unsupported_usage_source_routes_to_auto() { + for cookie_source in ["off", "manual"] { + let ctx = hyper_context(Some(cookie_source), "cli", None); + assert_eq!(ctx.source_mode, SourceMode::Auto, "{cookie_source}"); + assert!(ctx.manual_cookie_missing); + } +} + +#[test] +fn hyper_exposes_upstream_cookie_source_picker() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "hyper").as_deref(), + Some("auto") + ); + let options = super::cookie_source_options_for("hyper", Language::English); + let values: Vec<_> = options.iter().map(|option| option.value.as_str()).collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + let descriptions: Vec<_> = options + .iter() + .map(|option| option.description.as_deref()) + .collect(); + assert_eq!( + descriptions, + vec![ + Some("Prefer a signed-in Hyper browser session, then fall back to an API key."), + Some("Paste a Cookie header from hyper.charm.land."), + Some("Use only the configured API key."), + ] + ); +} diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 04f9388e80..aa501c7bef 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -264,6 +264,7 @@ fn main() { commands::get_provider_region_options, commands::set_provider_workspace_id, commands::set_provider_gateway_url, + commands::get_provider_gateway_url, commands::get_provider_workspace_id, commands::get_gemini_cli_signed_in, commands::get_vertexai_status, diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index aefe0035aa..7c65e9922e 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -185,7 +185,10 @@ export const PROVIDER_ICON_REGISTRY: Record = { gemini: { id: "gemini", brandColor: "#ab87ea", fallbackLetter: "✦", svgPath: RAW.gemini }, grok: { id: "grok", brandColor: "#111827", fallbackLetter: "G", svgPath: RAW.grok }, groq: { id: "groq", brandColor: "#f55036", fallbackLetter: "G", svgPath: RAW.groq }, + bifrost: { id: "bifrost", brandColor: "#33c09e", fallbackLetter: "B" }, + gitkraken: { id: "gitkraken", brandColor: "#179287", fallbackLetter: "G" }, huggingface: { id: "huggingface", brandColor: "#ffd21e", fallbackLetter: "H", svgPath: RAW.huggingface }, + hyper: { id: "hyper", brandColor: "#ff60ff", fallbackLetter: "H" }, helmcode: { id: "helmcode", brandColor: "#4f46e5", fallbackLetter: "H" }, v0: { id: "v0", brandColor: "#111827", fallbackLetter: "V" }, typesafe: { id: "typesafe", brandColor: "#2563eb", fallbackLetter: "T" }, diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 6ccd63c0de..57f6ce274d 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -450,6 +450,10 @@ export function setProviderGatewayUrl( return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); } +export function getProviderGatewayUrl(providerId: string): Promise { + return invoke("get_provider_gateway_url", { providerId }); +} + export function getProviderAzureApiVersion(providerId: string): Promise { return invoke("get_provider_azure_api_version", { providerId }); } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 0dadfe492d..ae3e6766a5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer } from "react"; +import { useCallback, useEffect, useReducer, useState } from "react"; import type { SettingsSnapshot, SettingsUpdate } from "../../../types/bridge"; import { useLocale } from "../../../hooks/useLocale"; import { providerAllowsPace } from "../../../lib/providerPace"; @@ -6,6 +6,7 @@ import { getCredentialStorageStatus, getProviderCookieSourceOptions, getProviderDetail, + getProviderGatewayUrl, getProviderRegionOptions, getTokenAccountProviders, openProviderDashboard, @@ -83,6 +84,8 @@ export function ProviderDetailPane({ onSettingsChange, }: Props) { const { t, language } = useLocale(); + const [gatewayLoadedProviderId, setGatewayLoadedProviderId] = + useState(null); const [state, dispatch] = useReducer( providerDetailPaneReducer, { wayfinderGatewayUrl, providerId }, @@ -142,11 +145,34 @@ export function ProviderDetailPane({ } }, []); + const gatewayProviderId = providerId === "wayfinder" || providerId === "bifrost" + ? providerId + : null; + + useEffect(() => { + setGatewayLoadedProviderId(null); + if (!gatewayProviderId) return; + let cancelled = false; + void getProviderGatewayUrl(gatewayProviderId).then((url) => { + if (!cancelled) { + dispatch({ type: "SET_GATEWAY_DRAFT", draft: url }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }).catch((e) => { + if (!cancelled) { + dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); + setGatewayLoadedProviderId(gatewayProviderId); + } + }); + return () => { cancelled = true; }; + }, [gatewayProviderId]); + const saveGateway = async () => { dispatch({ type: "SAVE_GATEWAY_START" }); try { - await setProviderGatewayUrl("wayfinder", gatewayDraft); - await load("wayfinder"); + if (!gatewayProviderId) return; + await setProviderGatewayUrl(gatewayProviderId, gatewayDraft); + await load(gatewayProviderId); } catch (e) { dispatch({ type: "SAVE_GATEWAY_ERROR", error: String(e) }); } finally { @@ -319,7 +345,8 @@ export function ProviderDetailPane({ t={t} onChanged={reload} /> - {detail.id === "wayfinder" && ( + {(detail.id === "wayfinder" || detail.id === "bifrost") && + gatewayLoadedProviderId === detail.id && ( void saveGateway()} t={t} + bifrost={detail.id === "bifrost"} /> )} ; case "openrouter": return ; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx index 1f0f28f0e6..77b4067771 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/WayfinderGatewaySection.tsx @@ -8,6 +8,7 @@ interface Props { onDraftChange: (draft: string) => void; onSave: () => void; t: (key: LocaleKey) => string; + bifrost?: boolean; } export function WayfinderGatewaySection({ @@ -18,12 +19,13 @@ export function WayfinderGatewaySection({ onDraftChange, onSave, t, + bifrost = false, }: Props) { return (
-

{t("WayfinderGatewayTitle")}

+

{bifrost ? "Bifrost gateway" : t("WayfinderGatewayTitle")}

-

{t("WayfinderGatewayHelp")}

+

+ {bifrost ? "Base URL of your Bifrost gateway." : t("WayfinderGatewayHelp")} +

{error &&

{error}

}