diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index f80945326c..d97b31f5dd 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -104,6 +104,7 @@ pub struct ProviderUsageSnapshot { pub account_organization: Option, pub tray_status_label: Option, pub fetch_duration_ms: Option, + pub wayfinder_usage: Option, } pub(crate) fn filter_hidden_codex_spark_rows( @@ -211,6 +212,7 @@ impl ProviderUsageSnapshot { account_organization: usage.account_organization.clone(), tray_status_label: None, fetch_duration_ms: None, + wayfinder_usage: result.wayfinder_usage.clone(), } } @@ -247,6 +249,7 @@ impl ProviderUsageSnapshot { account_organization: None, tray_status_label: None, fetch_duration_ms: None, + wayfinder_usage: None, } } } @@ -433,6 +436,7 @@ pub struct SettingsSnapshot { claude_avoid_keychain_prompts: bool, codex_spark_usage_visible: bool, disable_keychain_access: bool, + wayfinder_gateway_url: String, provider_metrics: std::collections::HashMap, float_bar_enabled: bool, float_bar_opacity: u8, @@ -469,6 +473,7 @@ impl From for SettingsSnapshot { fn from(settings: Settings) -> Self { let avoid_keychain_prompts = settings.claude_avoid_keychain_prompts(); let codex_spark_usage_visible = settings.codex_spark_usage_visible(); + let wayfinder_gateway_url = settings.gateway_url(ProviderId::Wayfinder).to_string(); let provider_order = settings.provider_display_order_names(); let enabled_providers = provider_order @@ -518,6 +523,7 @@ impl From for SettingsSnapshot { claude_avoid_keychain_prompts: avoid_keychain_prompts, codex_spark_usage_visible, disable_keychain_access: settings.disable_keychain_access, + wayfinder_gateway_url, provider_metrics, float_bar_enabled: settings.float_bar_enabled, float_bar_opacity: settings.float_bar_opacity, 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 1dc0cc1acf..2661b58f0d 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -298,6 +298,31 @@ pub fn get_provider_workspace_id(provider_id: String) -> Result, Ok((!value.is_empty()).then_some(value)) } +fn gateway_provider(provider_id: &str) -> Option { + (provider_id == "wayfinder").then_some(codexbar::core::ProviderId::Wayfinder) +} + +#[tauri::command] +pub fn get_provider_gateway_url(provider_id: String) -> Result, String> { + let Some(id) = gateway_provider(&provider_id) else { + return Ok(None); + }; + Ok(Some(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())?; + + let mut settings = Settings::load(); + settings.set_gateway_url(id, gateway_url.to_string()); + settings.save().map_err(|error| error.to_string()) +} + // ── Phase 6c — cookie source & region option catalogs ──────────────── #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index ff0b1c8d93..e16027b4d2 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -82,6 +82,8 @@ pub(crate) fn build_fetch_context( let workspace_id = settings.workspace_id(id).trim().to_string(); let api_region = settings.api_region(id).trim().to_string(); + let gateway_url = (id == ProviderId::Wayfinder && !settings.gateway_url(id).is_empty()) + .then(|| settings.gateway_url(id).to_string()); FetchContext { source_mode, @@ -89,6 +91,7 @@ pub(crate) fn build_fetch_context( api_key, workspace_id: (!workspace_id.is_empty()).then_some(workspace_id), api_region: (!api_region.is_empty()).then_some(api_region), + gateway_url, ..FetchContext::default() } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index fff68c06b3..32112e7521 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -680,6 +680,7 @@ fn provider_cache_upsert_replaces_existing_provider() { let result = ProviderFetchResult { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, + wayfinder_usage: None, source_label: "CLI".to_string(), }; let mut first = ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result); @@ -701,6 +702,7 @@ fn hiding_codex_spark_rows_preserves_other_extra_usage() { let result = ProviderFetchResult { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(10.0)), cost: None, + wayfinder_usage: None, source_label: "CLI".to_string(), }; let mut snapshot = @@ -730,6 +732,7 @@ fn claude_transient_auth_failure_preserves_first_last_good_snapshot() { let result = ProviderFetchResult { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, + wayfinder_usage: None, source_label: "OAuth".to_string(), }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result); @@ -757,6 +760,7 @@ fn claude_repeated_auth_failure_surfaces_error() { let result = ProviderFetchResult { usage: codexbar::core::UsageSnapshot::new(codexbar::core::RateWindow::new(42.0)), cost: None, + wayfinder_usage: None, source_label: "OAuth".to_string(), }; let good = ProviderUsageSnapshot::from_fetch_result(ProviderId::Claude, &metadata, &result); @@ -896,6 +900,7 @@ fn japanese_provider_snapshot_localizes_weekly_label() { let result = ProviderFetchResult { usage, cost: None, + wayfinder_usage: None, source_label: "OAuth".to_string(), }; @@ -923,6 +928,7 @@ fn japanese_provider_snapshot_localizes_pace_reserve_description() { let result = ProviderFetchResult { usage, cost: None, + wayfinder_usage: None, source_label: "OAuth".to_string(), }; diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index 92c10df007..89a81e169a 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -187,6 +187,8 @@ fn main() { commands::get_provider_region, commands::get_provider_region_options, commands::set_provider_workspace_id, + commands::get_provider_gateway_url, + commands::set_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-tauri/src/powertoys.rs b/apps/desktop-tauri/src-tauri/src/powertoys.rs index f6993099a4..3913d0ac86 100644 --- a/apps/desktop-tauri/src-tauri/src/powertoys.rs +++ b/apps/desktop-tauri/src-tauri/src/powertoys.rs @@ -203,6 +203,7 @@ mod tests { account_organization: Some("Example Org".to_string()), tray_status_label: None, fetch_duration_ms: None, + wayfinder_usage: None, }); let value = serde_json::to_value(snapshot).unwrap(); @@ -244,6 +245,7 @@ mod tests { account_organization: None, tray_status_label: None, fetch_duration_ms: None, + wayfinder_usage: None, }); let value = serde_json::to_value(snapshot).unwrap(); diff --git a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs index 74c34cc8b5..237069277d 100644 --- a/apps/desktop-tauri/src-tauri/src/tray_bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/tray_bridge.rs @@ -1086,6 +1086,7 @@ mod tests { account_organization: None, tray_status_label: None, fetch_duration_ms: None, + wayfinder_usage: None, } } diff --git a/apps/desktop-tauri/src/components/MenuCard.test.tsx b/apps/desktop-tauri/src/components/MenuCard.test.tsx index 502a13666d..6caefde0b4 100644 --- a/apps/desktop-tauri/src/components/MenuCard.test.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.test.tsx @@ -110,6 +110,14 @@ describe("MenuCard", () => { PanelThirtyDayTokens: "30d tokens", PanelTodayBudget: "today", PanelUsedSuffix: "used", + WayfinderGatewayStatus: "Gateway", + WayfinderModels: "Models", + WayfinderRequests: "Requests", + WayfinderTokens: "Tokens", + WayfinderSaved: "Saved", + WayfinderOffline: "Gateway offline", + WayfinderDryRun: "Dry run", + WayfinderMissingKeys: "Missing keys", }), ); tauriMocks.getProviderChartData.mockResolvedValue({ @@ -187,6 +195,42 @@ describe("MenuCard", () => { expect(screen.getByText("58% left")).toBeInTheDocument(); }); + it("renders Wayfinder telemetry without quota or identity rows", async () => { + const snapshot = provider(null); + snapshot.providerId = "wayfinder"; + snapshot.displayName = "Wayfinder"; + snapshot.accountEmail = "should-not-render@example.test"; + snapshot.planName = "should-not-render"; + snapshot.wayfinderUsage = { + gatewayStatus: "ok", + offline: false, + dryRun: false, + missingKeys: [], + modelCount: 2, + models: ["model-a", "model-b"], + requests: 14, + estimatedRequests: 0, + tokens: 1028, + realized: 0.004, + baseline: 0.01, + saved: 0.006, + savedPercent: 60, + periodDays: 30, + unit: "usd", + priced: true, + routes: [], + }; + + renderCard(snapshot); + + expect(await screen.findByText("ok")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("1K")).toBeInTheDocument(); + expect(screen.queryByText("should-not-render@example.test")).not.toBeInTheDocument(); + expect(screen.queryByText("should-not-render")).not.toBeInTheDocument(); + expect(screen.queryByText("Session")).not.toBeInTheDocument(); + }); + it("notifies the tray panel after async local usage data loads", async () => { const onLayoutChange = vi.fn(); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index 2415346d25..ce74ee24ab 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -181,6 +181,51 @@ function LocalUsageBlock({ ); } +function WayfinderUsageBlock({ + usage, +}: { + usage: NonNullable; +}) { + const { t } = useLocale(); + const formatAmount = (value: number) => + usage.priced ? `${value.toFixed(4)} ${usage.unit.toUpperCase()}` : "—"; + + return ( +
+
+
+ {t("WayfinderGatewayStatus")} + {usage.gatewayStatus} +
+
+ {t("WayfinderModels")} + {usage.modelCount} +
+
+ {t("WayfinderRequests")} + {formatCompactCount(usage.requests)} +
+
+ {t("WayfinderTokens")} + {formatCompactCount(usage.tokens)} +
+
+
+ {t("WayfinderSaved")}: {formatAmount(usage.saved)} ({usage.savedPercent.toFixed(1)}%) +
+ {(usage.offline || usage.dryRun || usage.missingKeys.length > 0) && ( +
+ {usage.offline && {t("WayfinderOffline")}} + {usage.dryRun && {t("WayfinderDryRun")}} + {usage.missingKeys.length > 0 && ( + {t("WayfinderMissingKeys")}: {usage.missingKeys.join(", ")} + )} +
+ )} +
+ ); +} + function displayPlanName(planName: string | null): string | null { if (!planName) return null; const normalized = planName.trim().toLowerCase(); @@ -397,19 +442,24 @@ export default function MenuCard({ }; }, [provider.providerId, provider.accountEmail, onLayoutChange]); - const email = provider.accountEmail + const isWayfinder = provider.providerId === "wayfinder"; + const email = !isWayfinder && provider.accountEmail ? hideEmail ? maskEmail(provider.accountEmail) : provider.accountEmail : null; - const planName = displayPlanName(provider.planName); + const planName = !isWayfinder ? displayPlanName(provider.planName) : null; const metrics: MetricEntry[] = [ - { - id: "primary", - label: provider.primaryLabel ?? t("DetailWindowPrimary"), - snap: provider.primary, - }, + ...(isWayfinder + ? [] + : [ + { + id: "primary", + label: provider.primaryLabel ?? t("DetailWindowPrimary"), + snap: provider.primary, + }, + ]), ]; if (provider.secondary) metrics.push({ @@ -450,8 +500,10 @@ export default function MenuCard({ const hasMetrics = visibleMetrics.length > 0; const hasCost = !!provider.cost; const hasPace = !!provider.pace; + const wayfinderUsage = isWayfinder ? provider.wayfinderUsage : null; const hasDetails = - !provider.error && (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage); + !provider.error && + (hasMetrics || hasCost || hasPace || hasCharts || !!localUsage || !!wayfinderUsage); const cardClassName = [ "menu-card", provider.error ? "menu-card--error" : null, @@ -514,6 +566,8 @@ export default function MenuCard({ )} + {wayfinderUsage && } + {localUsage && ( = { vertexai: { id: "vertexai", brandColor: "#4285f4", fallbackLetter: "△", svgPath: RAW.vertexai }, warp: { id: "warp", brandColor: "#6366f1", fallbackLetter: "W", svgPath: RAW.warp }, windsurf: { id: "windsurf", brandColor: "#22c55e", fallbackLetter: "W", svgPath: RAW.windsurf }, + wayfinder: { id: "wayfinder", brandColor: "#14b8a6", fallbackLetter: "W" }, zai: { id: "zai", brandColor: "#e85a6a", fallbackLetter: "Z", svgPath: RAW.zai }, // Aliases / Rust-side normalizations without their own SVG. nanogpt: { id: "nanogpt", brandColor: "#687fa1", fallbackLetter: "N" }, diff --git a/apps/desktop-tauri/src/i18n/keys.ts b/apps/desktop-tauri/src/i18n/keys.ts index 4fb4d007c9..b9478fcfb5 100644 --- a/apps/desktop-tauri/src/i18n/keys.ts +++ b/apps/desktop-tauri/src/i18n/keys.ts @@ -59,6 +59,17 @@ export const ALL_LOCALE_KEYS = [ "ProviderSourceGithubApiShort", "ProviderSourceLocalShort", "ProviderSourceKiroEnvShort", + "WayfinderGatewayTitle", + "WayfinderGatewayLabel", + "WayfinderGatewayHelp", + "WayfinderGatewayStatus", + "WayfinderModels", + "WayfinderRequests", + "WayfinderTokens", + "WayfinderSaved", + "WayfinderOffline", + "WayfinderDryRun", + "WayfinderMissingKeys", "TrackingItem", "MainWindowLiveUsageData", "StartTrackingUsage", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index f72382f1c5..67fd09155b 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -344,6 +344,17 @@ export function setProviderWorkspaceId( return invoke("set_provider_workspace_id", { providerId, workspaceId }); } +export function getProviderGatewayUrl(providerId: string): Promise { + return invoke("get_provider_gateway_url", { providerId }); +} + +export function setProviderGatewayUrl( + providerId: string, + gatewayUrl: string, +): Promise { + return invoke("set_provider_gateway_url", { providerId, gatewayUrl }); +} + // ── Phase 6d — credential detection ────────────────────────────────── export function openPath(path: string): Promise { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index fb498d4fae..22e1c79270 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -18,6 +18,7 @@ import { openProviderStatusPage, refreshProviders, revokeProviderCredentials, + setProviderGatewayUrl, triggerProviderLogin, } from "../../../lib/tauri"; import { listen } from "@tauri-apps/api/event"; @@ -47,6 +48,7 @@ interface Props { cookieDomain?: string | null; resetTimeRelative: boolean; providerMetrics: SettingsSnapshot["providerMetrics"]; + wayfinderGatewayUrl: string; settingsDisabled: boolean; onSettingsChange: (patch: SettingsUpdate) => void; } @@ -66,6 +68,7 @@ export function ProviderDetailPane({ cookieDomain = null, resetTimeRelative, providerMetrics, + wayfinderGatewayUrl, settingsDisabled, onSettingsChange, }: Props) { @@ -82,6 +85,26 @@ export function ProviderDetailPane({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [gatewayDraft, setGatewayDraft] = useState(wayfinderGatewayUrl); + const [gatewayError, setGatewayError] = useState(null); + + useEffect(() => { + if (providerId === "wayfinder") setGatewayDraft(wayfinderGatewayUrl); + setGatewayError(null); + }, [providerId, wayfinderGatewayUrl]); + + const saveGateway = async () => { + setBusy(true); + setGatewayError(null); + try { + await setProviderGatewayUrl("wayfinder", gatewayDraft); + await load("wayfinder"); + } catch (e) { + setGatewayError(String(e)); + } finally { + setBusy(false); + } + }; // Load the set of providers that support token accounts once. useEffect(() => { @@ -274,6 +297,26 @@ export function ProviderDetailPane({ resetTimeRelative={resetTimeRelative} t={t} /> + {detail.id === "wayfinder" && ( +
+

{t("WayfinderGatewayTitle")}

+ +

{t("WayfinderGatewayHelp")}

+ {gatewayError &&

{gatewayError}

} + +
+ )} diff --git a/apps/desktop-tauri/src/types/bridge.ts b/apps/desktop-tauri/src/types/bridge.ts index 2179f5de49..151937b009 100644 --- a/apps/desktop-tauri/src/types/bridge.ts +++ b/apps/desktop-tauri/src/types/bridge.ts @@ -100,7 +100,8 @@ export type ProofProviderId = | "zed" | "crossmodel" | "qoder" - | "sakana"; + | "sakana" + | "wayfinder"; export type TrayPanelSurfaceTarget = { kind: "summary" }; export type PopOutSurfaceTarget = @@ -202,6 +203,7 @@ export interface SettingsSnapshot { claudeAvoidKeychainPrompts: boolean; codexSparkUsageVisible: boolean; disableKeychainAccess: boolean; + wayfinderGatewayUrl?: string; providerMetrics: Record; floatBarEnabled: boolean; /** 30..=100 — clamped server-side. */ @@ -332,6 +334,36 @@ export interface ProviderUsageSnapshot { accountOrganization: string | null; trayStatusLabel: string | null; fetchDurationMs?: number | null; + wayfinderUsage?: WayfinderUsageSnapshot | null; +} + +export interface WayfinderRouteSummary { + name: string; + requests: number; + tokens: number; + realized: number; + baseline: number; + saved: number; +} + +export interface WayfinderUsageSnapshot { + gatewayStatus: string; + offline: boolean; + dryRun: boolean; + missingKeys: string[]; + modelCount: number; + models: string[]; + requests: number; + estimatedRequests: number; + tokens: number; + realized: number; + baseline: number; + saved: number; + savedPercent: number; + periodDays: number; + unit: string; + priced: boolean; + routes: WayfinderRouteSummary[]; } export interface RefreshCompletePayload { diff --git a/rust/src/cli/diagnose.rs b/rust/src/cli/diagnose.rs index 64d0918f6e..f0e1f9cb1e 100644 --- a/rust/src/cli/diagnose.rs +++ b/rust/src/cli/diagnose.rs @@ -178,6 +178,9 @@ async fn collect_provider_diagnostic( api_region: settings .provider_config(provider_id) .and_then(|config| config.api_region.clone()), + gateway_url: settings + .provider_config(provider_id) + .and_then(|config| config.gateway_url.clone()), }; let fetch_result = provider.fetch_usage(&ctx).await; diff --git a/rust/src/cli/serve.rs b/rust/src/cli/serve.rs index 06a3ebeaa9..2944aef674 100644 --- a/rust/src/cli/serve.rs +++ b/rust/src/cli/serve.rs @@ -84,6 +84,7 @@ async fn usage_response(provider: Option<&str>) -> String { api_key: None, workspace_id: None, api_region: None, + gateway_url: None, }; let mut results = Vec::new(); diff --git a/rust/src/cli/usage.rs b/rust/src/cli/usage.rs index 6c1302db5c..5cef80ea66 100755 --- a/rust/src/cli/usage.rs +++ b/rust/src/cli/usage.rs @@ -204,6 +204,7 @@ fn build_usage_fetch_context(args: &UsageArgs, source_mode: SourceMode) -> Fetch api_key: None, workspace_id: None, api_region: None, + gateway_url: None, } } diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index d47715678a..36f9d634c6 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -67,6 +67,7 @@ pub enum ProviderId { CrossModel, Qoder, Sakana, + Wayfinder, } impl ProviderId { @@ -129,6 +130,7 @@ impl ProviderId { ProviderId::CrossModel, ProviderId::Qoder, ProviderId::Sakana, + ProviderId::Wayfinder, ] } @@ -191,6 +193,7 @@ impl ProviderId { ProviderId::CrossModel => "crossmodel", ProviderId::Qoder => "qoder", ProviderId::Sakana => "sakana", + ProviderId::Wayfinder => "wayfinder", } } @@ -253,6 +256,7 @@ impl ProviderId { ProviderId::CrossModel => "CrossModel", ProviderId::Qoder => "Qoder", ProviderId::Sakana => "Sakana AI", + ProviderId::Wayfinder => "Wayfinder", } } @@ -320,6 +324,7 @@ impl ProviderId { ProviderId::Devin => None, ProviderId::Zed => None, ProviderId::CrossModel => None, + ProviderId::Wayfinder => None, } } @@ -389,6 +394,7 @@ impl ProviderId { "crossmodel" | "cross-model" | "cross model" => Some(ProviderId::CrossModel), "qoder" => Some(ProviderId::Qoder), "sakana" | "sakana-ai" | "sakana ai" => Some(ProviderId::Sakana), + "wayfinder" => Some(ProviderId::Wayfinder), _ => None, } } @@ -498,6 +504,9 @@ pub struct FetchContext { /// Optional provider API/web region from persisted settings. pub api_region: Option, + + /// Optional provider gateway URL, used by local gateway-backed providers. + pub gateway_url: Option, } impl Default for FetchContext { @@ -511,6 +520,7 @@ impl Default for FetchContext { api_key: None, workspace_id: None, api_region: None, + gateway_url: None, } } } @@ -617,7 +627,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 56); + assert_eq!(all.len(), 57); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Kimi)); @@ -654,6 +664,7 @@ mod tests { assert!(all.contains(&ProviderId::CrossModel)); assert!(all.contains(&ProviderId::Qoder)); assert!(all.contains(&ProviderId::Sakana)); + assert!(all.contains(&ProviderId::Wayfinder)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 4d1ccf00fe..feab9e0d0d 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -17,7 +17,7 @@ use crate::providers::{ MistralProvider, NanoGPTProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PoeProvider, QoderProvider, SakanaProvider, StepFunProvider, T3ChatProvider, VeniceProvider, VertexAIProvider, - WarpProvider, WindsurfProvider, ZaiProvider, ZedProvider, + WarpProvider, WayfinderProvider, WindsurfProvider, ZaiProvider, ZedProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -82,6 +82,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::CrossModel => Box::new(CrossModelProvider::new()), ProviderId::Qoder => Box::new(QoderProvider::new()), ProviderId::Sakana => Box::new(SakanaProvider::new()), + ProviderId::Wayfinder => Box::new(WayfinderProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index ba0bf57cb9..fed6002cab 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -236,7 +236,8 @@ impl TokenAccountSupport { | ProviderId::Poe | ProviderId::Devin | ProviderId::Zed - | ProviderId::CrossModel => None, + | ProviderId::CrossModel + | ProviderId::Wayfinder => None, } } diff --git a/rust/src/core/usage_snapshot.rs b/rust/src/core/usage_snapshot.rs index 34d940237a..116ffc02f6 100755 --- a/rust/src/core/usage_snapshot.rs +++ b/rust/src/core/usage_snapshot.rs @@ -5,6 +5,39 @@ use serde::{Deserialize, Serialize}; use super::RateWindow; +/// Provider-specific operational data reported by a Wayfinder gateway. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WayfinderUsageSnapshot { + pub gateway_status: String, + pub offline: bool, + pub dry_run: bool, + pub missing_keys: Vec, + pub model_count: usize, + pub models: Vec, + pub requests: u64, + pub estimated_requests: u64, + pub tokens: u64, + pub realized: f64, + pub baseline: f64, + pub saved: f64, + pub saved_percent: f64, + pub period_days: u32, + pub unit: String, + pub priced: bool, + pub routes: Vec, +} + +/// Per-route savings data reported by Wayfinder. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WayfinderRouteSummary { + pub name: String, + pub requests: u64, + pub tokens: u64, + pub realized: f64, + pub baseline: f64, + pub saved: f64, +} + /// A labeled extra usage window surfaced by provider APIs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NamedRateWindow { @@ -273,6 +306,10 @@ pub struct ProviderFetchResult { #[serde(skip_serializing_if = "Option::is_none")] pub cost: Option, + /// Provider-specific operational data that is not quota or identity data. + #[serde(skip_serializing_if = "Option::is_none")] + pub wayfinder_usage: Option, + /// Label describing the data source (e.g., "oauth", "web", "cli") pub source_label: String, } @@ -283,6 +320,7 @@ impl ProviderFetchResult { Self { usage, cost: None, + wayfinder_usage: None, source_label: source_label.into(), } } @@ -292,6 +330,12 @@ impl ProviderFetchResult { self.cost = Some(cost); self } + + /// Builder pattern: set Wayfinder operational data. + pub fn with_wayfinder_usage(mut self, usage: WayfinderUsageSnapshot) -> Self { + self.wayfinder_usage = Some(usage); + self + } } #[cfg(test)] diff --git a/rust/src/locale.rs b/rust/src/locale.rs index c1657667c1..10f4ad6556 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -221,6 +221,17 @@ locale_keys! { ProviderSourceGithubApiShort, ProviderSourceLocalShort, ProviderSourceKiroEnvShort, + WayfinderGatewayTitle, + WayfinderGatewayLabel, + WayfinderGatewayHelp, + WayfinderGatewayStatus, + WayfinderModels, + WayfinderRequests, + WayfinderTokens, + WayfinderSaved, + WayfinderOffline, + WayfinderDryRun, + WayfinderMissingKeys, TrackingItem, MainWindowLiveUsageData, StartTrackingUsage, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 13a4239518..772a1e675f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = api ProviderSourceGithubApiShort = github api ProviderSourceLocalShort = local ProviderSourceKiroEnvShort = kiro env +WayfinderGatewayTitle = Wayfinder Gateway +WayfinderGatewayLabel = Gateway URL +WayfinderGatewayHelp = Use HTTP only for localhost or loopback addresses. HTTPS is allowed for remote hosts. +WayfinderGatewayStatus = Gateway +WayfinderModels = Models +WayfinderRequests = Requests +WayfinderTokens = Tokens +WayfinderSaved = Saved +WayfinderOffline = Gateway offline +WayfinderDryRun = Dry run +WayfinderMissingKeys = Missing keys TrackingItem = Tracked Item MainWindowLiveUsageData = Live usage data in main window StartTrackingUsage = Enable to start tracking usage diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index d7a67d206f..2cf1940a77 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = api ProviderSourceGithubApiShort = api de github ProviderSourceLocalShort = local ProviderSourceKiroEnvShort = entorno kiro +WayfinderGatewayTitle = Gateway de Wayfinder +WayfinderGatewayLabel = URL del gateway +WayfinderGatewayHelp = Usa HTTP solo para localhost o direcciones de loopback. HTTPS permite hosts remotos. +WayfinderGatewayStatus = Gateway +WayfinderModels = Modelos +WayfinderRequests = Solicitudes +WayfinderTokens = Tokens +WayfinderSaved = Ahorrado +WayfinderOffline = Gateway sin conexión +WayfinderDryRun = Simulación +WayfinderMissingKeys = Claves faltantes TrackingItem = Elemento rastreado MainWindowLiveUsageData = Datos de uso en vivo en ventana principal StartTrackingUsage = Habilitar para comenzar a rastrear el uso diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index 60657acd06..5f40384af2 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = api ProviderSourceGithubApiShort = github api ProviderSourceLocalShort = local ProviderSourceKiroEnvShort = kiro env +WayfinderGatewayTitle = Wayfinder ゲートウェイ +WayfinderGatewayLabel = ゲートウェイ URL +WayfinderGatewayHelp = HTTP は localhost またはループバックアドレスでのみ使用できます。リモートホストには HTTPS を使用します。 +WayfinderGatewayStatus = ゲートウェイ +WayfinderModels = モデル +WayfinderRequests = リクエスト +WayfinderTokens = トークン +WayfinderSaved = 節約 +WayfinderOffline = ゲートウェイオフライン +WayfinderDryRun = ドライラン +WayfinderMissingKeys = 不足しているキー TrackingItem = Tracked Item MainWindowLiveUsageData = Live usage data in main window StartTrackingUsage = Enable to start tracking usage diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 455528798a..338353024a 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = API ProviderSourceGithubApiShort = GitHub API ProviderSourceLocalShort = 로컬 ProviderSourceKiroEnvShort = Kiro Env +WayfinderGatewayTitle = Wayfinder 게이트웨이 +WayfinderGatewayLabel = 게이트웨이 URL +WayfinderGatewayHelp = HTTP는 localhost 또는 루프백 주소에서만 사용하세요. 원격 호스트에는 HTTPS를 사용합니다. +WayfinderGatewayStatus = 게이트웨이 +WayfinderModels = 모델 +WayfinderRequests = 요청 +WayfinderTokens = 토큰 +WayfinderSaved = 절약 +WayfinderOffline = 게이트웨이 오프라인 +WayfinderDryRun = 드라이 런 +WayfinderMissingKeys = 누락된 키 TrackingItem = 추적 대상 MainWindowLiveUsageData = 기본 창의 실시간 사용량 데이터 StartTrackingUsage = 사용량 추적을 시작하려면 활성화 diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 4be46d15f5..4f79b87826 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = API ProviderSourceGithubApiShort = GitHub API ProviderSourceLocalShort = 本地 ProviderSourceKiroEnvShort = Kiro 环境 +WayfinderGatewayTitle = Wayfinder 网关 +WayfinderGatewayLabel = 网关 URL +WayfinderGatewayHelp = HTTP 仅可用于 localhost 或回环地址。远程主机请使用 HTTPS。 +WayfinderGatewayStatus = 网关 +WayfinderModels = 模型 +WayfinderRequests = 请求 +WayfinderTokens = 令牌 +WayfinderSaved = 节省 +WayfinderOffline = 网关离线 +WayfinderDryRun = 试运行 +WayfinderMissingKeys = 缺少密钥 TrackingItem = 追踪项 MainWindowLiveUsageData = 主窗口实时用量数据 StartTrackingUsage = 启用后开始追踪用量 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index d043630fa4..85a1ec9011 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -52,6 +52,17 @@ ProviderSourceApiShort = API ProviderSourceGithubApiShort = GitHub API ProviderSourceLocalShort = 本機 ProviderSourceKiroEnvShort = Kiro 環境 +WayfinderGatewayTitle = Wayfinder 閘道 +WayfinderGatewayLabel = 閘道 URL +WayfinderGatewayHelp = HTTP 僅可用於 localhost 或迴路位址。遠端主機請使用 HTTPS。 +WayfinderGatewayStatus = 閘道 +WayfinderModels = 模型 +WayfinderRequests = 請求 +WayfinderTokens = 權杖 +WayfinderSaved = 節省 +WayfinderOffline = 閘道離線 +WayfinderDryRun = 試執行 +WayfinderMissingKeys = 缺少金鑰 TrackingItem = 追蹤項 MainWindowLiveUsageData = 主視窗實時用量資料 StartTrackingUsage = 啟用後開始追蹤用量 diff --git a/rust/src/providers/fixtures/wayfinder/health.json b/rust/src/providers/fixtures/wayfinder/health.json new file mode 100644 index 0000000000..8c2072b972 --- /dev/null +++ b/rust/src/providers/fixtures/wayfinder/health.json @@ -0,0 +1 @@ +{"status":"ok","models":["cloud","local"],"offline":false} diff --git a/rust/src/providers/fixtures/wayfinder/models.json b/rust/src/providers/fixtures/wayfinder/models.json new file mode 100644 index 0000000000..5bf6715280 --- /dev/null +++ b/rust/src/providers/fixtures/wayfinder/models.json @@ -0,0 +1 @@ +{"models":[{"name":"local","endpoint":"http://127.0.0.1:9101/v1","model":"stand-in-small","api_key_env":null,"key_ok":true},{"name":"cloud","endpoint":"http://127.0.0.1:9102/v1","model":"stand-in-large","api_key_env":"RIG_CLOUD_KEY","key_ok":true}],"dry_run":false} diff --git a/rust/src/providers/fixtures/wayfinder/savings.json b/rust/src/providers/fixtures/wayfinder/savings.json new file mode 100644 index 0000000000..606a00f4e0 --- /dev/null +++ b/rust/src/providers/fixtures/wayfinder/savings.json @@ -0,0 +1 @@ +{"period_days":30,"unit":"usd","priced":true,"requests":14,"estimated_requests":0,"tokens":1028,"realized":0.003558,"baseline":0.009252,"saved":0.005694,"saved_pct":61.5,"by_route":{"cloud":{"requests":4,"realized":0.003294,"baseline":0.003294,"saved":0.0,"tokens":366},"local":{"requests":10,"realized":0.000264,"baseline":0.005958,"saved":0.005694,"tokens":662}},"by_key":{},"price_table_version":"a3db80fd9a78"} diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 75e063f787..1584fdbba0 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -56,6 +56,7 @@ pub mod t3chat; pub mod venice; pub mod vertexai; pub mod warp; +pub mod wayfinder; pub mod windsurf; pub mod zai; pub mod zed; @@ -114,6 +115,7 @@ pub use t3chat::T3ChatProvider; pub use venice::VeniceProvider; pub use vertexai::VertexAIProvider; pub use warp::WarpProvider; +pub use wayfinder::WayfinderProvider; pub use windsurf::WindsurfProvider; pub use zai::ZaiProvider; pub use zed::ZedProvider; diff --git a/rust/src/providers/wayfinder.rs b/rust/src/providers/wayfinder.rs new file mode 100644 index 0000000000..02bf0ddb56 --- /dev/null +++ b/rust/src/providers/wayfinder.rs @@ -0,0 +1,354 @@ +//! Wayfinder gateway provider. +//! +//! Wayfinder is a local-first gateway, so it has no account, quota, or +//! credential data. The provider exposes only gateway health, configured +//! models, and savings telemetry. + +use async_trait::async_trait; +use reqwest::Url; +use serde::Deserialize; +use serde::de::DeserializeOwned; +use std::collections::BTreeMap; +use std::net::IpAddr; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, WayfinderRouteSummary, WayfinderUsageSnapshot, + credentialed_http_client_builder, +}; + +pub const DEFAULT_GATEWAY_URL: &str = "http://127.0.0.1:8088"; + +const HEALTH_PATH: &str = "healthz"; +const MODELS_PATH: &str = "router/models"; +const SAVINGS_PATH: &str = "v1/savings?period=30d"; +const METRICS_PATH: &str = "metrics"; + +#[derive(Debug, Clone, Deserialize)] +pub struct HealthResponse { + pub status: String, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub offline: bool, + #[serde(default)] + pub missing_keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelsResponse { + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub dry_run: bool, +} + +impl ModelsResponse { + pub fn model_count(&self) -> usize { + self.models.len() + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ModelResponse { + pub name: String, + #[serde(default)] + pub endpoint: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub api_key_env: Option, + #[serde(default)] + pub key_ok: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SavingsResponse { + pub period_days: u32, + pub unit: String, + pub priced: bool, + pub requests: u64, + #[serde(default)] + pub estimated_requests: u64, + pub tokens: u64, + pub realized: f64, + pub baseline: f64, + pub saved: f64, + pub saved_pct: f64, + #[serde(default)] + pub by_route: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RouteResponse { + pub requests: u64, + pub realized: f64, + pub baseline: f64, + pub saved: f64, + pub tokens: u64, +} + +pub fn parse_gateway_url(raw: &str) -> Result { + let trimmed = raw.trim(); + let url = Url::parse(trimmed) + .map_err(|_| ProviderError::Other("Wayfinder gateway URL is invalid".to_string()))?; + + if url.scheme() != "http" + || url.username() != "" + || url.password().is_some() + || url.fragment().is_some() + || url.host_str().is_none() + { + return Err(ProviderError::Other( + "Wayfinder gateway URL must be HTTP without credentials or a fragment".to_string(), + )); + } + + let scheme_end = trimmed.find("://").map(|index| index + 3).unwrap_or(0); + let authority_end = trimmed[scheme_end..] + .find(['/', '?', '#']) + .map(|index| scheme_end + index) + .unwrap_or(trimmed.len()); + let authority = &trimmed[scheme_end..authority_end]; + if authority.contains('%') || authority.chars().any(char::is_whitespace) { + return Err(ProviderError::Other( + "Wayfinder gateway URL contains an encoded or invalid host".to_string(), + )); + } + + if !is_loopback_host(&url) { + return Err(ProviderError::Other( + "Wayfinder gateway must use localhost or a loopback address".to_string(), + )); + } + + Ok(url) +} + +fn is_loopback_host(url: &Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_matches(['[', ']']); + if host.eq_ignore_ascii_case("localhost") { + return true; + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +pub fn endpoint_url(base: &Url, path: &str) -> Result { + let mut base = base.clone(); + let base_path = base.path().trim_end_matches('/'); + base.set_path(&format!("{base_path}/")); + base.join(path) + .map_err(|_| ProviderError::Other("Wayfinder gateway path is invalid".to_string())) +} + +pub fn parse_health(raw: &str) -> Result { + serde_json::from_str(raw) + .map_err(|_| ProviderError::Parse("Invalid Wayfinder health response".to_string())) +} + +pub fn parse_models(raw: &str) -> Result { + serde_json::from_str(raw) + .map_err(|_| ProviderError::Parse("Invalid Wayfinder models response".to_string())) +} + +pub fn parse_savings(raw: &str) -> Result { + serde_json::from_str(raw) + .map_err(|_| ProviderError::Parse("Invalid Wayfinder savings response".to_string())) +} + +pub struct WayfinderProvider { + metadata: ProviderMetadata, +} + +impl WayfinderProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Wayfinder, + display_name: "Wayfinder", + session_label: "Gateway", + weekly_label: "Savings", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: None, + status_page_url: None, + }, + } + } + + async fn fetch_json( + client: &reqwest::Client, + url: &Url, + ) -> Result { + let response = client.get(url.clone()).send().await?; + if !response.status().is_success() { + return Err(ProviderError::Other(format!( + "Wayfinder gateway returned HTTP {}", + response.status() + ))); + } + response + .json::() + .await + .map_err(|_| ProviderError::Parse("Invalid Wayfinder gateway response".to_string())) + } + + async fn fetch_metrics(client: &reqwest::Client, url: &Url) -> Result<(), ProviderError> { + let response = client.get(url.clone()).send().await?; + if response.status().is_success() { + let _ = response.text().await?; + } + Ok(()) + } +} + +impl Default for WayfinderProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for WayfinderProvider { + fn id(&self) -> ProviderId { + ProviderId::Wayfinder + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + if ctx.source_mode != SourceMode::Auto { + return Err(ProviderError::UnsupportedSource(ctx.source_mode)); + } + + let base = parse_gateway_url(ctx.gateway_url.as_deref().unwrap_or(DEFAULT_GATEWAY_URL))?; + let client = credentialed_http_client_builder() + .timeout(std::time::Duration::from_secs(ctx.web_timeout.max(1))) + .build() + .map_err(ProviderError::Network)?; + + let health: HealthResponse = + Self::fetch_json(&client, &endpoint_url(&base, HEALTH_PATH)?).await?; + let models: ModelsResponse = + Self::fetch_json(&client, &endpoint_url(&base, MODELS_PATH)?).await?; + let savings: SavingsResponse = + Self::fetch_json(&client, &endpoint_url(&base, SAVINGS_PATH)?).await?; + let _ = Self::fetch_metrics(&client, &endpoint_url(&base, METRICS_PATH)?).await; + + let wayfinder_usage = WayfinderUsageSnapshot { + gateway_status: health.status, + offline: health.offline, + dry_run: models.dry_run, + missing_keys: health.missing_keys, + model_count: models.model_count(), + models: models.models.into_iter().map(|model| model.name).collect(), + requests: savings.requests, + estimated_requests: savings.estimated_requests, + tokens: savings.tokens, + realized: savings.realized, + baseline: savings.baseline, + saved: savings.saved, + saved_percent: savings.saved_pct, + period_days: savings.period_days, + unit: savings.unit, + priced: savings.priced, + routes: savings + .by_route + .into_iter() + .map(|(name, route)| WayfinderRouteSummary { + name, + requests: route.requests, + tokens: route.tokens, + realized: route.realized, + baseline: route.baseline, + saved: route.saved, + }) + .collect(), + }; + + Ok( + ProviderFetchResult::new(UsageSnapshot::new(RateWindow::new(0.0)), "gateway") + .with_wayfinder_usage(wayfinder_usage), + ) + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_loopback_http() { + for raw in [ + "http://127.0.0.1:8088", + "http://127.42.1.9:8088", + "http://localhost:8088", + "http://[::1]:8088", + ] { + assert!(parse_gateway_url(raw).is_ok(), "{raw}"); + } + } + + #[test] + fn rejects_unsafe_gateway_urls() { + for raw in [ + "127.0.0.1:8088", + "ftp://127.0.0.1:8088", + "https://127.0.0.1:8088", + "https://gateway.example.test/wayfinder", + "http://192.168.1.20:8088", + "http://user:password@127.0.0.1:8088", + "http://127.0.0.1:8088/#fragment", + "http://127.0.0.1%2f.attacker.test:8088", + "https://proxy.test%2f.attacker.test/v1", + ] { + assert!(parse_gateway_url(raw).is_err(), "{raw}"); + } + } + + #[test] + fn endpoint_paths_preserve_gateway_prefix() { + let base = parse_gateway_url("http://localhost:8088/wayfinder/").unwrap(); + assert_eq!( + endpoint_url(&base, "healthz").unwrap().as_str(), + "http://localhost:8088/wayfinder/healthz" + ); + } + + #[test] + fn parses_upstream_fixtures_without_identity_or_quota_fields() { + let health = parse_health(include_str!("fixtures/wayfinder/health.json")).unwrap(); + let models = parse_models(include_str!("fixtures/wayfinder/models.json")).unwrap(); + let savings = parse_savings(include_str!("fixtures/wayfinder/savings.json")).unwrap(); + + assert_eq!(health.status, "ok"); + assert!(!health.offline); + assert!(health.missing_keys.is_empty()); + assert_eq!(models.model_count(), 2); + assert!(!models.dry_run); + assert_eq!(savings.requests, 14); + assert_eq!(savings.tokens, 1028); + assert_eq!(savings.by_route["local"].requests, 10); + assert_eq!(savings.saved, 0.005694); + } + + #[test] + fn malformed_payloads_are_rejected() { + assert!(parse_health("{\"status\":").is_err()); + assert!(parse_models("{\"models\": [").is_err()); + assert!(parse_savings("{\"requests\": \"many\"}").is_err()); + } +} diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 95e70000d4..a204738435 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -710,6 +710,24 @@ impl Settings { self.provider_config_mut(id).workspace_id = Some(value.into()); } + /// Wayfinder gateway URL, defaulting to the local loopback gateway. + pub fn gateway_url(&self, id: ProviderId) -> &str { + self.provider_configs + .get(&id) + .and_then(|c| c.gateway_url.as_deref()) + .unwrap_or_else(|| { + if id == ProviderId::Wayfinder { + crate::providers::wayfinder::DEFAULT_GATEWAY_URL + } else { + "" + } + }) + } + + pub fn set_gateway_url(&mut self, id: ProviderId, value: impl Into) { + self.provider_config_mut(id).gateway_url = Some(value.into()); + } + /// IDE base path override for `id`, or `""` if unset. pub fn ide_base_path(&self, id: ProviderId) -> &str { self.provider_configs diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index e9ff1f0768..f582a6a035 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -192,6 +192,27 @@ fn test_settings_provider_enabled() { assert!(settings.is_provider_enabled(ProviderId::Claude)); assert!(settings.is_provider_enabled(ProviderId::Codex)); assert!(!settings.is_provider_enabled(ProviderId::Gemini)); + assert!(!settings.is_provider_enabled(ProviderId::Wayfinder)); + assert_eq!( + settings.gateway_url(ProviderId::Wayfinder), + "http://127.0.0.1:8088" + ); +} + +#[test] +fn wayfinder_gateway_round_trips_without_changing_settings_paths() { + let mut settings = Settings::default(); + settings.set_gateway_url( + ProviderId::Wayfinder, + "https://gateway.example.test/wayfinder/", + ); + + let json = serde_json::to_string(&settings).expect("serialize settings"); + let loaded: Settings = serde_json::from_str(&json).expect("deserialize settings"); + assert_eq!( + loaded.gateway_url(ProviderId::Wayfinder), + "https://gateway.example.test/wayfinder/" + ); } #[test] diff --git a/rust/src/settings/types.rs b/rust/src/settings/types.rs index c0b124d4ff..753f2affec 100644 --- a/rust/src/settings/types.rs +++ b/rust/src/settings/types.rs @@ -257,6 +257,9 @@ pub struct ProviderConfig { pub api_token: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_id: Option, + /// Wayfinder gateway URL override. + #[serde(skip_serializing_if = "Option::is_none")] + pub gateway_url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ide_base_path: Option, /// Codex-only: opt out of OpenAI web "extras" surfaces.