Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
67 changes: 63 additions & 4 deletions apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ fn cookie_source_provider(provider_id: &str) -> Option<codexbar::core::ProviderI
"replicate" => ProviderId::Replicate,
"helmcode" => ProviderId::Helmcode,
"typesafe" => ProviderId::TypeSafe,
"hyper" => ProviderId::Hyper,
_ => return None,
})
}
Expand Down Expand Up @@ -334,6 +335,7 @@ fn workspace_provider(provider_id: &str) -> Option<codexbar::core::ProviderId> {
"xai" => ProviderId::Xai,
"v0" => ProviderId::V0,
"helmcode" => ProviderId::Helmcode,
"gitkraken" => ProviderId::GitKraken,
_ => return None,
})
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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!(
Expand Down Expand Up @@ -450,16 +464,36 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result<Option<String>,
}

fn gateway_provider(provider_id: &str) -> Option<codexbar::core::ProviderId> {
(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<String, String> {
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]
pub fn set_provider_gateway_url(provider_id: String, gateway_url: String) -> Result<(), String> {
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());
Expand Down Expand Up @@ -786,6 +820,31 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec<Cooki
None,
),
],
// Upstream's Hyper picker; the session can come from any selected
// browser here, not only Chrome.
"hyper" => 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(),
}
}
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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."),
]
);
}
1 change: 1 addition & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/components/providers/providerIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,10 @@ export const PROVIDER_ICON_REGISTRY: Record<string, ProviderIcon> = {
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" },
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,10 @@ export function setProviderGatewayUrl(
return invoke<void>("set_provider_gateway_url", { providerId, gatewayUrl });
}

export function getProviderGatewayUrl(providerId: string): Promise<string> {
return invoke<string>("get_provider_gateway_url", { providerId });
}

export function getProviderAzureApiVersion(providerId: string): Promise<string | null> {
return invoke<string | null>("get_provider_azure_api_version", { providerId });
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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";
import {
getCredentialStorageStatus,
getProviderCookieSourceOptions,
getProviderDetail,
getProviderGatewayUrl,
getProviderRegionOptions,
getTokenAccountProviders,
openProviderDashboard,
Expand Down Expand Up @@ -83,6 +84,8 @@ export function ProviderDetailPane({
onSettingsChange,
}: Props) {
const { t, language } = useLocale();
const [gatewayLoadedProviderId, setGatewayLoadedProviderId] =
useState<string | null>(null);
const [state, dispatch] = useReducer(
providerDetailPaneReducer,
{ wayfinderGatewayUrl, providerId },
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -319,7 +345,8 @@ export function ProviderDetailPane({
t={t}
onChanged={reload}
/>
{detail.id === "wayfinder" && (
{(detail.id === "wayfinder" || detail.id === "bifrost") &&
gatewayLoadedProviderId === detail.id && (
<WayfinderGatewaySection
draft={gatewayDraft}
error={gatewayError}
Expand All @@ -330,6 +357,7 @@ export function ProviderDetailPane({
}
onSave={() => void saveGateway()}
t={t}
bifrost={detail.id === "bifrost"}
/>
)}
<MenuBarMetricSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function CredentialsDispatcher({ providerId, t }: Props) {
case "zed":
case "sub2api":
case "xai":
case "gitkraken":
return <OpenAiExtras providerId={providerId} t={t} />;
case "openrouter":
return <OpenRouterManagementCreds t={t} />;
Expand Down
Loading