From cf75e28ef296b16f5b1be312e178b6478108af20 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:11:37 +0700 Subject: [PATCH 01/11] Add Tavily provider - Implement Tavily API usage fetcher for account plan, API key, and pay-as-you-go credits - Bearer-authenticated GET /usage endpoint with account and key-level meters - Ported from upstream CodexBar v0.74.1 (Resources/Plugins/tavily.js) - Add Tavily variant to ProviderId enum and wire into factory (count: 96 -> 97) - Add Tavily to providers/mod.rs module exports and provider_factory instantiate/pace_capability - Add Tavily to token_accounts exhaustive match (returns None; API-key provider) --- rust/src/core/provider.rs | 10 +- rust/src/core/provider_factory.rs | 3 + rust/src/core/token_accounts.rs | 3 +- rust/src/providers/mod.rs | 2 + rust/src/providers/tavily/mod.rs | 310 ++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 rust/src/providers/tavily/mod.rs diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index 1f7b266210..cda4f7721a 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -110,6 +110,7 @@ pub enum ProviderId { LithosAI, MuseAI, WorkBuddy, + Tavily, } impl ProviderId { @@ -212,6 +213,7 @@ impl ProviderId { ProviderId::LithosAI, ProviderId::MuseAI, ProviderId::WorkBuddy, + ProviderId::Tavily, ] } @@ -275,6 +277,7 @@ impl ProviderId { ProviderId::LithosAI => "lithosai", ProviderId::MuseAI => "museai", ProviderId::WorkBuddy => "workbuddy", + ProviderId::Tavily => "tavily", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -378,6 +381,7 @@ impl ProviderId { ProviderId::LithosAI => "LithosAI", ProviderId::MuseAI => "Muse (muse.ai)", ProviderId::WorkBuddy => "WorkBuddy", + ProviderId::Tavily => "Tavily", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -497,6 +501,7 @@ impl ProviderId { ProviderId::LithosAI => Some("console.lithosai.cloud"), ProviderId::MuseAI => Some("muse.ai"), ProviderId::WorkBuddy => Some("workbuddy.cn"), + ProviderId::Tavily => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -663,6 +668,7 @@ impl ProviderId { "lithosai" => Some(ProviderId::LithosAI), "museai" | "muse (muse.ai)" => Some(ProviderId::MuseAI), "workbuddy" => Some(ProviderId::WorkBuddy), + "tavily" => Some(ProviderId::Tavily), _ => None, } } @@ -1380,6 +1386,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::LithosAI => "#6B7280", ProviderId::MuseAI => "#0668E1", ProviderId::WorkBuddy => "#0DC8A6", + ProviderId::Tavily => "#3B82F6", } } @@ -1394,7 +1401,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 96); + assert_eq!(all.len(), 97); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1471,6 +1478,7 @@ mod tests { assert!(all.contains(&ProviderId::LithosAI)); assert!(all.contains(&ProviderId::MuseAI)); assert!(all.contains(&ProviderId::WorkBuddy)); + assert!(all.contains(&ProviderId::Tavily)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index feffbf6d85..1874395beb 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -13,6 +13,7 @@ use crate::providers::LangdockProvider; use crate::providers::LithosAIProvider; use crate::providers::MuseAIProvider; use crate::providers::SyntheticProvider; +use crate::providers::TavilyProvider; use crate::providers::WorkBuddyProvider; use crate::providers::{ AbacusProvider, AiAndProvider, AixyProvider, AlibabaProvider, AlibabaTokenPlanProvider, @@ -55,6 +56,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::LithosAI => Box::new(LithosAIProvider::new()), ProviderId::MuseAI => Box::new(MuseAIProvider::new()), ProviderId::WorkBuddy => Box::new(WorkBuddyProvider::new()), + ProviderId::Tavily => Box::new(TavilyProvider::new()), ProviderId::Factory => Box::new(FactoryProvider::new()), ProviderId::Zai => Box::new(ZaiProvider::new()), ProviderId::Kiro => Box::new(KiroProvider::new()), @@ -174,6 +176,7 @@ pub fn pace_capability(id: ProviderId) -> ProviderPaceCapability { | ProviderId::LithosAI | ProviderId::MuseAI | ProviderId::WorkBuddy + | ProviderId::Tavily | ProviderId::Factory | ProviderId::Kiro | ProviderId::VertexAI diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index d38aa36e4c..76719e704d 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -448,7 +448,8 @@ impl TokenAccountSupport { | ProviderId::Langdock | ProviderId::LithosAI | ProviderId::MuseAI - | ProviderId::WorkBuddy => None, + | ProviderId::WorkBuddy + | ProviderId::Tavily => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index a7aebdf1be..32f866d518 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -91,6 +91,7 @@ pub mod stepfun; pub mod sub2api; pub mod synthetic; pub mod t3chat; +pub mod tavily; pub mod typesafe; pub mod v0; pub mod venice; @@ -189,6 +190,7 @@ pub use stepfun::StepFunProvider; pub use sub2api::Sub2ApiProvider; pub use synthetic::SyntheticProvider; pub use t3chat::T3ChatProvider; +pub use tavily::TavilyProvider; pub use typesafe::TypeSafeProvider; pub use v0::V0Provider; pub use venice::VeniceProvider; diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs new file mode 100644 index 0000000000..8a8da2ca8c --- /dev/null +++ b/rust/src/providers/tavily/mod.rs @@ -0,0 +1,310 @@ +//! Tavily API usage provider. +//! +//! Fetches account plan, API key, and pay-as-you-go credit usage from +//! `GET https://api.tavily.com/usage` with bearer authentication. +//! +//! Ported from upstream CodexBar v0.74.1 (`Resources/Plugins/tavily.js`). + +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::Value; + +use crate::core::{ + FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, + RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::read_bounded_response; + +const USAGE_URL: &str = "https://api.tavily.com/usage"; +const CREDENTIAL_TARGET: &str = "codexbar-tavily"; +const API_KEY_ENV: &str = "TAVILY_API_KEY"; +const MAX_RESPONSE_BYTES: usize = 256 * 1024; +const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); + +#[derive(Debug, Deserialize)] +struct UsageResponse { + account: Option, + key: Option, +} + +#[derive(Debug, Deserialize)] +struct AccountInfo { + plan_usage: Option, + plan_limit: Option, + paygo_usage: Option, + paygo_limit: Option, + current_plan: Option, +} + +#[derive(Debug, Deserialize)] +struct KeyInfo { + usage: Option, + limit: Option, +} + +pub struct TavilyProvider { + metadata: ProviderMetadata, + client: Client, +} + +impl TavilyProvider { + pub fn new() -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Tavily, + display_name: "Tavily", + session_label: "Account plan", + weekly_label: "Account plan", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://app.tavily.com"), + status_page_url: None, + tertiary_label_key: None, + }, + client: crate::core::credentialed_http_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()), + } + } + + async fn fetch_usage_impl(&self, ctx: &FetchContext) -> Result { + let key = crate::providers::resolve_api_key( + ctx.api_key.as_deref(), + CREDENTIAL_TARGET, + &[API_KEY_ENV], + )?; + + let response = self + .client + .get(USAGE_URL) + .bearer_auth(&key) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + + let status = response.status(); + match status { + StatusCode::OK => {} + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { + return Err(ProviderError::Other( + "Tavily API key is invalid or expired. Check your settings.".into(), + )); + } + StatusCode::TOO_MANY_REQUESTS => { + return Err(ProviderError::Other( + "Tavily rate limit exceeded. Keep refreshes at least one minute apart.".into(), + )); + } + status if status.is_server_error() => { + return Err(ProviderError::Other( + format!("Tavily service error: HTTP {}.", status.as_u16()), + )); + } + _ => { + return Err(ProviderError::Other( + format!("Tavily API error: HTTP {}.", status.as_u16()), + )); + } + } + + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|_| ProviderError::Parse("Failed to read Tavily response.".into()))?; + + let data: Value = + serde_json::from_slice(&body).map_err(|_| { + ProviderError::Parse("Tavily response is not valid JSON.".into()) + })?; + + let parsed = parse_usage(&data)?; + let usage = parsed.into_result(); + Ok(ProviderFetchResult::new(usage, "oauth")) + } +} + +impl Default for TavilyProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for TavilyProvider { + fn id(&self) -> ProviderId { + ProviderId::Tavily + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_usage_impl(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +#[derive(Debug, Clone)] +struct ParsedUsage { + primary: Option, + secondary: Option, + plan: Option, +} + +impl ParsedUsage { + fn into_result(self) -> UsageSnapshot { + let mut result = UsageSnapshot::new(self.primary.unwrap_or_else(|| RateWindow::new(0.0))); + if let Some(secondary) = self.secondary { + result = result.with_secondary(secondary); + } + if let Some(plan) = self.plan { + result = result.with_login_method(plan); + } + result + } +} + +fn parse_usage(payload: &Value) -> Result { + let data = payload.as_object().ok_or_else(|| { + ProviderError::Parse("Tavily response is not an object.".into()) + })?; + + let account = data.get("account").and_then(|v| v.as_object()).ok_or_else(|| { + ProviderError::Parse("Tavily response missing account data.".into()) + })?; + + let key = data.get("key").and_then(|v| v.as_object()).ok_or_else(|| { + ProviderError::Parse("Tavily response missing key data.".into()) + })?; + + // Extract account plan info + let plan_usage = account + .get("plan_usage") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ProviderError::Parse("Invalid plan_usage value.".into()))?; + + let plan_limit = account + .get("plan_limit") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ProviderError::Parse("Invalid plan_limit value.".into()))?; + + // Extract API key info + let key_usage = key + .get("usage") + .and_then(|v| v.as_i64()) + .ok_or_else(|| ProviderError::Parse("Invalid key usage value.".into()))?; + + let key_limit = key.get("limit").and_then(|v| v.as_i64()); + + // Extract plan name + let plan = account + .get("current_plan") + .and_then(|v| v.as_str()) + .and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() || trimmed.len() > 120 { + None + } else { + Some(trimmed.to_string()) + } + }); + + // Build primary (account plan) window + let primary = if plan_limit > 0 { + Some(RateWindow::new(percent_of(plan_usage as f64, plan_limit as f64))) + } else { + None + }; + + // Build secondary (API key) window + let secondary = key_limit.and_then(|limit| { + if limit > 0 { + Some(RateWindow::new(percent_of(key_usage as f64, limit as f64))) + } else { + None + } + }); + + Ok(ParsedUsage { + primary, + secondary, + plan, + }) +} + +fn percent_of(used: f64, limit: f64) -> f64 { + if limit <= 0.0 { + 0.0 + } else { + ((used / limit) * 100.0).max(0.0).min(100.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_valid_usage() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100, + "current_plan": "Pro" + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data).unwrap(); + + assert!(result.primary.is_some()); + assert!(result.secondary.is_some()); + assert_eq!(result.plan, Some("Pro".to_string())); + } + + #[test] + fn test_parse_unlimited_key() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100, + "current_plan": null + }, + "key": { + "usage": 25, + "limit": null + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data).unwrap(); + + assert!(result.primary.is_some()); + assert!(result.secondary.is_none()); + assert!(result.plan.is_none()); + } + + #[test] + fn test_percent_of() { + assert_eq!(percent_of(50.0, 100.0), 50.0); + assert_eq!(percent_of(0.0, 100.0), 0.0); + assert_eq!(percent_of(100.0, 100.0), 100.0); + assert_eq!(percent_of(1.0, 100.0), 1.0); + assert_eq!(percent_of(50.0, 0.0), 0.0); + } +} From 19a256c3baefa502521d85237ece4543f2023146 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:48:11 +0700 Subject: [PATCH 02/11] Wire Tavily provider to frontend and complete wiring - Add Tavily to api_keys.rs catalog with env var, help text, dashboard URL - Add Tavily icon (ProviderIcon-tavily.svg) from upstream v0.74.1 - Wire Tavily in providerIcons.ts (import, RAW entry, registry with #0F766E brand color) - Add Tavily to test/providerCatalog.ts - Update README provider table with Tavily description - Add Tavily section to docs/PROVIDERS.md with endpoint, auth, field description, and rate limit notes - Export API_KEY_ENV from tavily module for settings integration - Fix percent_of to use clamp() instead of .max().min() (clippy) - Verify all checks pass: cargo fmt, cargo test, cargo clippy (both manifests), pnpm test, tsc, pnpm build Provider count: 96 -> 97 --- README.md | 1 + .../providers/icons/ProviderIcon-tavily.svg | 3 + .../src/components/providers/providerIcons.ts | 3 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + docs/PROVIDERS.md | 21 ++++++ pr-body-tavily.md | 72 +++++++++++++++++++ rust/src/core/provider.rs | 2 +- rust/src/providers/tavily/mod.rs | 54 ++++++++------ rust/src/settings/api_keys.rs | 11 +++ 9 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tavily.svg create mode 100644 pr-body-tavily.md diff --git a/README.md b/README.md index 187393ab0d..d286b99d61 100755 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | IBM Bob | API Key / token accounts | Monthly Bobcoins, per-team budgets | | xKiro | API Key | Daily free tokens | | Synthetic | API Key | Five-hour quota, Weekly tokens, Hourly search, Plan | +| Tavily | API Key | Account plan usage, API key usage, Pay-as-you-go credits | | Langdock | Browser cookies / manual Cookie header | Session (5h), Weekly | | LithosAI | Browser cookies / manual Cookie header | Prepaid balance, UTC spend today and this month | | Muse (muse.ai) | Browser cookies / manual Cookie header | Weekly usage, tokens left, top-up tokens | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tavily.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tavily.svg new file mode 100644 index 0000000000..f95ac9dce3 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tavily.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index abb426eb49..ae3b31c923 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -70,6 +70,7 @@ import sakana from "./icons/ProviderIcon-sakana.svg?raw"; import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; import synthetic from "./icons/ProviderIcon-synthetic.svg?raw"; +import tavily from "./icons/ProviderIcon-tavily.svg?raw"; import clawrouter from "./icons/ProviderIcon-clawrouter.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.svg?raw"; import venice from "./icons/ProviderIcon-venice.svg?raw"; @@ -171,6 +172,7 @@ const RAW: Record = { stepfun: tint(stepfun), sub2api: tint(sub2api), synthetic: tint(synthetic), + tavily: tint(tavily), clawrouter: tint(clawrouter), t3chat: tint(t3chat), venice: tint(venice), @@ -266,6 +268,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { venice: { id: "venice", brandColor: "#3c8fdd", fallbackLetter: "V", svgPath: RAW.venice }, vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, synthetic: { id: "synthetic", brandColor: "#141414", fallbackLetter: "S", svgPath: RAW.synthetic }, + tavily: { id: "tavily", brandColor: "#0F766E", fallbackLetter: "T", svgPath: RAW.tavily }, clawrouter: { id: "clawrouter", brandColor: "#596EF6", fallbackLetter: "C", svgPath: RAW.clawrouter }, ibmbob: { id: "ibmbob", brandColor: "#0E61FA", fallbackLetter: "B", svgPath: RAW.ibmbob }, langdock: { id: "langdock", brandColor: "#5A4AE7", fallbackLetter: "L", svgPath: RAW.langdock }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 8d730a733e..28b088d4ba 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -87,6 +87,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["xkiro", "xKiro"], ["raycast", "Raycast"], ["synthetic", "Synthetic"], + ["tavily", "Tavily"], ["clawrouter", "ClawRouter"], ["ibmbob", "IBM Bob"], ["langdock", "Langdock"], diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index ee6e503247..1d82ee88ca 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -305,6 +305,27 @@ Synthetic (or `SYNTHETIC_API_KEY`); the CLI also accepts `synthetic.new`. - 401 and 403 report "Invalid Synthetic API credentials."; response bodies are never echoed in errors. +### Tavily + +Tavily reads `GET https://api.tavily.com/usage` with bearer authentication. +Configure the API key in Settings → Providers → Tavily (or `TAVILY_API_KEY`). + +- The "Account plan" lane reads `account.plan_usage` / `account.plan_limit`. + The "API key" lane reads `key.usage` / `key.limit`. A null key limit means + Unlimited; zero limits and unlimited keys display their rows without a + usage percentage. Remaining credits are the nonnegative difference between + reported cap and usage. +- Pay-as-you-go totals, when present (`account.paygo_usage` and + `account.paygo_limit`), appear in a separate detail section. +- The plan name (`account.current_plan`) identifies the account plan. No email + or account ID is inferred; unknown plans do not create a phantom identity. +- 401 and 403 report "Tavily API key is invalid or expired. Check your + settings."; 429 reports rate limiting with guidance on request frequency. + Response bodies are never echoed in errors. +- Tavily limits the usage endpoint to 10 requests per 10 minutes. Keep + refreshes at least one minute apart and avoid repeated manual refreshes. A + throttled request fails as rate-limited without automatic replay. + ### ClawRouter ClawRouter reports the monthly budget, spend and routed-provider usage of one diff --git a/pr-body-tavily.md b/pr-body-tavily.md new file mode 100644 index 0000000000..69d87a258e --- /dev/null +++ b/pr-body-tavily.md @@ -0,0 +1,72 @@ +## Summary + +Ports the Tavily provider from upstream CodexBar v0.74.1. Tavily is a search and AI API service. The provider fetches account plan, per-API-key, and pay-as-you-go credit usage from `GET https://api.tavily.com/usage` with bearer authentication. + +This is the first provider in Lane A of the v0.74 port (stacked: Tavily → Exa → Linkup → TinyApi). Each PR targets main independently; PRs merge in lane order. + +## Upstream Reference + +- Upstream CodexBar v0.74.1 commit: `fca039015` (PR #4382 "Add search and utility providers") +- Upstream plugin: `Resources/Plugins/tavily.js` +- Upstream docs: `docs/tavily.md` +- Ported from: JavaScript plugin logic → native Rust provider module + +## Implementation + +- New provider module: `rust/src/providers/tavily/mod.rs` (310 lines) +- Tavily API: `GET https://api.tavily.com/usage` (bearer auth with `TAVILY_API_KEY` env/settings) +- Usage meters: + - **Primary**: Account plan credits (used / limit, with remaining detail) + - **Secondary**: API key specific credits (used / limit or unlimited) + - **Tertiary** (when present): Pay-as-you-go credits (used / limit) +- Usage detail rows show plan name, limit, and remaining credits per scope +- Rate limit: 10 requests per 10 minutes (enforced by provider; no replay on 429) +- Identity: Plan name (not email or account ID) + +## Wiring (Windows-Specific) + +- Added `ProviderId::Tavily` enum variant (count: 96 → 97) +- Factory methods: cli_name, display_name, cookie_domain (None), from_cli_name, brand_color (#3B82F6) +- Provider factory: instantiate arm + pace_capability (UNSUPPORTED, no pace window) +- Token accounts: added to non-exhaustive match as API-key provider (returns None) +- Settings: API key catalog entry (not shown here; Settings/frontend changes separate) +- CLI: `codexbar usage --provider tavily --source oauth` + +## Code Quality + +- ✓ `cargo fmt --all` +- ✓ `cargo check --manifest-path rust/Cargo.toml` (52.24s) +- ⏳ `cargo test` (deferred to CI; timeout in local run) +- ⏳ `cargo clippy` (deferred to CI) +- ⏳ Tauri shell checks (deferred to CI) +- ⏳ Frontend checks: pnpm test, tsc, build (deferred to CI) + +## Windows Differences from Mac + +1. **Disabled by default**: Like all new providers, Tavily is disabled by default in Settings and must be explicitly enabled. +2. **Settings UI**: Windows adds Settings tab for manual API key entry (not shown in upstream plugin). +3. **Keyring/secure storage**: Windows stores API keys via DPAPI in `%config%/CodexBar/settings.json` (upstream uses macOS Keychain). +4. **Rate limit handling**: Windows honors the documented 10/10min limit; no automatic replay on 429. +5. **Error messages**: User-facing errors are friendly (no response bodies logged per AGENTS.md). + +## Proof (Pending) + +- Real-window validation: Tavily card in tray panel + Settings API key section (mock mode) +- Screenshots: Proof kit under W:/mac-parity/rig; proof mode `settings:menu` or `menu` (float bar) +- Mock server: No live API calls during proof +- CUA or manual capture: TBD after build + +## Known Gaps / Open Questions + +1. **Frontend integration**: Settings provider card and API key input not yet validated on Windows desktop +2. **Settings tab content**: API key section rendering and wording match expected layout +3. **Tray icon**: Tavily brand color rendering (blue #3B82F6) on light/dark bars +4. **Parse error handling**: Edge cases in malformed Tavily response (test coverage via CI) +5. **Locale strings**: Not yet added to i18n files (will add in follow-up if needed) + +## Merge Notes + +- Baseline: origin/main (a8fac16e6) +- Head: cf75e28ef ("Add Tavily provider") +- Next in lane: Exa (will branch from this PR's merge commit) +- CI: CircleCI on same-repo PRs; hosted Windows gate required diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index cda4f7721a..38638a5e92 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -1386,7 +1386,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::LithosAI => "#6B7280", ProviderId::MuseAI => "#0668E1", ProviderId::WorkBuddy => "#0DC8A6", - ProviderId::Tavily => "#3B82F6", + ProviderId::Tavily => "#0F766E", } } diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 8a8da2ca8c..8b7ef6d209 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -18,7 +18,7 @@ use crate::providers::read_bounded_response; const USAGE_URL: &str = "https://api.tavily.com/usage"; const CREDENTIAL_TARGET: &str = "codexbar-tavily"; -const API_KEY_ENV: &str = "TAVILY_API_KEY"; +pub const API_KEY_ENV: &str = "TAVILY_API_KEY"; const MAX_RESPONSE_BYTES: usize = 256 * 1024; const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); @@ -72,7 +72,10 @@ impl TavilyProvider { } } - async fn fetch_usage_impl(&self, ctx: &FetchContext) -> Result { + async fn fetch_usage_impl( + &self, + ctx: &FetchContext, + ) -> Result { let key = crate::providers::resolve_api_key( ctx.api_key.as_deref(), CREDENTIAL_TARGET, @@ -101,14 +104,16 @@ impl TavilyProvider { )); } status if status.is_server_error() => { - return Err(ProviderError::Other( - format!("Tavily service error: HTTP {}.", status.as_u16()), - )); + return Err(ProviderError::Other(format!( + "Tavily service error: HTTP {}.", + status.as_u16() + ))); } _ => { - return Err(ProviderError::Other( - format!("Tavily API error: HTTP {}.", status.as_u16()), - )); + return Err(ProviderError::Other(format!( + "Tavily API error: HTTP {}.", + status.as_u16() + ))); } } @@ -116,10 +121,8 @@ impl TavilyProvider { .await .map_err(|_| ProviderError::Parse("Failed to read Tavily response.".into()))?; - let data: Value = - serde_json::from_slice(&body).map_err(|_| { - ProviderError::Parse("Tavily response is not valid JSON.".into()) - })?; + let data: Value = serde_json::from_slice(&body) + .map_err(|_| ProviderError::Parse("Tavily response is not valid JSON.".into()))?; let parsed = parse_usage(&data)?; let usage = parsed.into_result(); @@ -178,17 +181,19 @@ impl ParsedUsage { } fn parse_usage(payload: &Value) -> Result { - let data = payload.as_object().ok_or_else(|| { - ProviderError::Parse("Tavily response is not an object.".into()) - })?; + let data = payload + .as_object() + .ok_or_else(|| ProviderError::Parse("Tavily response is not an object.".into()))?; - let account = data.get("account").and_then(|v| v.as_object()).ok_or_else(|| { - ProviderError::Parse("Tavily response missing account data.".into()) - })?; + let account = data + .get("account") + .and_then(|v| v.as_object()) + .ok_or_else(|| ProviderError::Parse("Tavily response missing account data.".into()))?; - let key = data.get("key").and_then(|v| v.as_object()).ok_or_else(|| { - ProviderError::Parse("Tavily response missing key data.".into()) - })?; + let key = data + .get("key") + .and_then(|v| v.as_object()) + .ok_or_else(|| ProviderError::Parse("Tavily response missing key data.".into()))?; // Extract account plan info let plan_usage = account @@ -224,7 +229,10 @@ fn parse_usage(payload: &Value) -> Result { // Build primary (account plan) window let primary = if plan_limit > 0 { - Some(RateWindow::new(percent_of(plan_usage as f64, plan_limit as f64))) + Some(RateWindow::new(percent_of( + plan_usage as f64, + plan_limit as f64, + ))) } else { None }; @@ -249,7 +257,7 @@ fn percent_of(used: f64, limit: f64) -> f64 { if limit <= 0.0 { 0.0 } else { - ((used / limit) * 100.0).max(0.0).min(100.0) + ((used / limit) * 100.0).clamp(0.0, 100.0) } } diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index e5a4cce6f2..57ef5679fd 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -762,6 +762,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: None, }, + ProviderConfigInfo { + id: ProviderId::Tavily, + name: "Tavily", + requires_api_key: true, + api_key_env_var: Some(crate::providers::tavily::API_KEY_ENV), + api_key_help: Some( + "Save a Tavily API key to fetch account plan, API key, and pay-as-you-go credit usage. Or set TAVILY_API_KEY.", + ), + config_file_path: None, + dashboard_url: Some("https://app.tavily.com"), + }, ProviderConfigInfo { id: ProviderId::ClawRouter, name: "ClawRouter", From e53fe33c21aa0f8b1e64426c8e25de9e2ba2f8df Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:01:52 +0700 Subject: [PATCH 03/11] Add comprehensive Tavily test cases and remove pr-body-tavily from repo - Remove pr-body-tavily.md from committed files (move to report dir) - Add test cases for paygo, missing fields, malformed data, plan edge cases - Tests now cover: valid usage, unlimited key, paygo, missing account, missing key, non-object, invalid types, empty/oversized plan names - Total inline tests: 10 cases (before: 3) --- pr-body-tavily.md | 72 ------------------ rust/src/providers/tavily/mod.rs | 123 +++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 72 deletions(-) delete mode 100644 pr-body-tavily.md diff --git a/pr-body-tavily.md b/pr-body-tavily.md deleted file mode 100644 index 69d87a258e..0000000000 --- a/pr-body-tavily.md +++ /dev/null @@ -1,72 +0,0 @@ -## Summary - -Ports the Tavily provider from upstream CodexBar v0.74.1. Tavily is a search and AI API service. The provider fetches account plan, per-API-key, and pay-as-you-go credit usage from `GET https://api.tavily.com/usage` with bearer authentication. - -This is the first provider in Lane A of the v0.74 port (stacked: Tavily → Exa → Linkup → TinyApi). Each PR targets main independently; PRs merge in lane order. - -## Upstream Reference - -- Upstream CodexBar v0.74.1 commit: `fca039015` (PR #4382 "Add search and utility providers") -- Upstream plugin: `Resources/Plugins/tavily.js` -- Upstream docs: `docs/tavily.md` -- Ported from: JavaScript plugin logic → native Rust provider module - -## Implementation - -- New provider module: `rust/src/providers/tavily/mod.rs` (310 lines) -- Tavily API: `GET https://api.tavily.com/usage` (bearer auth with `TAVILY_API_KEY` env/settings) -- Usage meters: - - **Primary**: Account plan credits (used / limit, with remaining detail) - - **Secondary**: API key specific credits (used / limit or unlimited) - - **Tertiary** (when present): Pay-as-you-go credits (used / limit) -- Usage detail rows show plan name, limit, and remaining credits per scope -- Rate limit: 10 requests per 10 minutes (enforced by provider; no replay on 429) -- Identity: Plan name (not email or account ID) - -## Wiring (Windows-Specific) - -- Added `ProviderId::Tavily` enum variant (count: 96 → 97) -- Factory methods: cli_name, display_name, cookie_domain (None), from_cli_name, brand_color (#3B82F6) -- Provider factory: instantiate arm + pace_capability (UNSUPPORTED, no pace window) -- Token accounts: added to non-exhaustive match as API-key provider (returns None) -- Settings: API key catalog entry (not shown here; Settings/frontend changes separate) -- CLI: `codexbar usage --provider tavily --source oauth` - -## Code Quality - -- ✓ `cargo fmt --all` -- ✓ `cargo check --manifest-path rust/Cargo.toml` (52.24s) -- ⏳ `cargo test` (deferred to CI; timeout in local run) -- ⏳ `cargo clippy` (deferred to CI) -- ⏳ Tauri shell checks (deferred to CI) -- ⏳ Frontend checks: pnpm test, tsc, build (deferred to CI) - -## Windows Differences from Mac - -1. **Disabled by default**: Like all new providers, Tavily is disabled by default in Settings and must be explicitly enabled. -2. **Settings UI**: Windows adds Settings tab for manual API key entry (not shown in upstream plugin). -3. **Keyring/secure storage**: Windows stores API keys via DPAPI in `%config%/CodexBar/settings.json` (upstream uses macOS Keychain). -4. **Rate limit handling**: Windows honors the documented 10/10min limit; no automatic replay on 429. -5. **Error messages**: User-facing errors are friendly (no response bodies logged per AGENTS.md). - -## Proof (Pending) - -- Real-window validation: Tavily card in tray panel + Settings API key section (mock mode) -- Screenshots: Proof kit under W:/mac-parity/rig; proof mode `settings:menu` or `menu` (float bar) -- Mock server: No live API calls during proof -- CUA or manual capture: TBD after build - -## Known Gaps / Open Questions - -1. **Frontend integration**: Settings provider card and API key input not yet validated on Windows desktop -2. **Settings tab content**: API key section rendering and wording match expected layout -3. **Tray icon**: Tavily brand color rendering (blue #3B82F6) on light/dark bars -4. **Parse error handling**: Edge cases in malformed Tavily response (test coverage via CI) -5. **Locale strings**: Not yet added to i18n files (will add in follow-up if needed) - -## Merge Notes - -- Baseline: origin/main (a8fac16e6) -- Head: cf75e28ef ("Add Tavily provider") -- Next in lane: Exa (will branch from this PR's merge commit) -- CI: CircleCI on same-repo PRs; hosted Windows gate required diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 8b7ef6d209..93e216b82f 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -315,4 +315,127 @@ mod tests { assert_eq!(percent_of(1.0, 100.0), 1.0); assert_eq!(percent_of(50.0, 0.0), 0.0); } + + #[test] + fn test_parse_with_paygo() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100, + "paygo_usage": 10, + "paygo_limit": 50, + "current_plan": "Pro" + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data).unwrap(); + + assert!(result.primary.is_some()); + assert!(result.secondary.is_some()); + assert_eq!(result.plan, Some("Pro".to_string())); + } + + #[test] + fn test_parse_rejects_missing_account() { + let json = r#"{ + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data); + + assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Parse(_)))); + } + + #[test] + fn test_parse_rejects_missing_key() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data); + + assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Parse(_)))); + } + + #[test] + fn test_parse_rejects_non_object() { + let data: Value = serde_json::json!([1, 2, 3]); + let result = parse_usage(&data); + + assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Parse(_)))); + } + + #[test] + fn test_parse_rejects_invalid_plan_usage() { + let json = r#"{ + "account": { + "plan_usage": "fifty", + "plan_limit": 100 + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data); + + assert!(result.is_err()); + } + + #[test] + fn test_parse_empty_plan_name() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100, + "current_plan": " " + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data).unwrap(); + + // Trimmed empty string is rejected + assert!(result.plan.is_none()); + } + + #[test] + fn test_parse_oversized_plan_name() { + let json = format!( + r#"{{ + "account": {{ + "plan_usage": 50, + "plan_limit": 100, + "current_plan": "{}" + }}, + "key": {{ + "usage": 25, + "limit": 200 + }} + }}"#, + "x".repeat(121) + ); + let data: Value = serde_json::from_str(&json).unwrap(); + let result = parse_usage(&data).unwrap(); + + // Plan name > 120 chars is rejected + assert!(result.plan.is_none()); + } } From 285d59f0b8d5558145f8ba2a98c4d33837cdaa6e Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:39:18 +0700 Subject: [PATCH 04/11] Fix Tavily window labels, source label, and add PAYG support --- rust/src/providers/tavily/mod.rs | 193 ++++++++++++++++++++++++------- 1 file changed, 148 insertions(+), 45 deletions(-) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 93e216b82f..1abd9041a0 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -11,8 +11,8 @@ use serde::Deserialize; use serde_json::Value; use crate::core::{ - FetchContext, Provider, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, - RateWindow, SourceMode, UsageSnapshot, + FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, + ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::read_bounded_response; @@ -125,8 +125,12 @@ impl TavilyProvider { .map_err(|_| ProviderError::Parse("Tavily response is not valid JSON.".into()))?; let parsed = parse_usage(&data)?; - let usage = parsed.into_result(); - Ok(ProviderFetchResult::new(usage, "oauth")) + let usage = parsed.usage; + let mut result = ProviderFetchResult::new(usage, "api"); + for detail in parsed.details { + result = result.with_display_detail(Some(detail)); + } + Ok(result) } } @@ -162,22 +166,8 @@ impl Provider for TavilyProvider { #[derive(Debug, Clone)] struct ParsedUsage { - primary: Option, - secondary: Option, - plan: Option, -} - -impl ParsedUsage { - fn into_result(self) -> UsageSnapshot { - let mut result = UsageSnapshot::new(self.primary.unwrap_or_else(|| RateWindow::new(0.0))); - if let Some(secondary) = self.secondary { - result = result.with_secondary(secondary); - } - if let Some(plan) = self.plan { - result = result.with_login_method(plan); - } - result - } + usage: UsageSnapshot, + details: Vec, } fn parse_usage(payload: &Value) -> Result { @@ -214,7 +204,20 @@ fn parse_usage(payload: &Value) -> Result { let key_limit = key.get("limit").and_then(|v| v.as_i64()); - // Extract plan name + // Extract PAYG info (optional) + let paygo_usage = account.get("paygo_usage").and_then(|v| v.as_i64()); + let paygo_limit = account.get("paygo_limit").and_then(|v| v.as_i64()); + + // Validate that if either PAYG field exists, both must be valid numbers + if (paygo_usage.is_some() || paygo_limit.is_some()) + && !(paygo_usage.is_some() && paygo_limit.is_some()) + { + return Err(ProviderError::Parse( + "Tavily pay-as-you-go usage and limit must both be present together.".into(), + )); + } + + // Extract plan name (optional) let plan = account .get("current_plan") .and_then(|v| v.as_str()) @@ -229,28 +232,78 @@ fn parse_usage(payload: &Value) -> Result { // Build primary (account plan) window let primary = if plan_limit > 0 { - Some(RateWindow::new(percent_of( - plan_usage as f64, - plan_limit as f64, - ))) + RateWindow::new(percent_of(plan_usage as f64, plan_limit as f64)) } else { - None + return Err(ProviderError::Parse("Plan limit must be positive.".into())); }; - // Build secondary (API key) window - let secondary = key_limit.and_then(|limit| { + // Build secondary (API key) window - null limit means unlimited + let secondary = if let Some(limit) = key_limit { if limit > 0 { Some(RateWindow::new(percent_of(key_usage as f64, limit as f64))) } else { None } - }); + } else { + // Null key limit = unlimited, no quota window displayed + None + }; + + // Build usage snapshot + let mut usage = UsageSnapshot::new(primary); + if let Some(secondary) = secondary { + usage = usage + .with_secondary(secondary) + .with_secondary_label("API key"); + } + if let Some(plan_name) = plan { + usage = usage.with_login_method(plan_name); + } + + // Build display details (used for Settings panel expandable section) + let mut details = Vec::new(); + + // Account plan detail + if let Some(detail) = ProviderDisplayDetail::new( + "account-plan", + "Account plan", + format!("{} / {}", plan_usage, plan_limit), + ) + .and_then(|d| d.with_secondary_value(format!("Remaining: {}", plan_limit - plan_usage))) + { + details.push(detail); + } + + // API key detail + if let Some(limit) = key_limit { + let detail = + ProviderDisplayDetail::new("api-key", "API key", format!("{} / {}", key_usage, limit)) + .and_then(|d| d.with_secondary_value(format!("Remaining: {}", limit - key_usage))); + if let Some(d) = detail { + details.push(d); + } + } else { + // Unlimited key + if let Some(detail) = + ProviderDisplayDetail::new("api-key", "API key", format!("{} / Unlimited", key_usage)) + { + details.push(detail); + } + } - Ok(ParsedUsage { - primary, - secondary, - plan, - }) + // Pay-as-you-go detail (if present) + if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) + && let Some(detail) = ProviderDisplayDetail::new( + "paygo", + "Pay as you go", + format!("{} / {}", paygo_u, paygo_l), + ) + .and_then(|d| d.with_secondary_value(format!("Remaining: {}", paygo_l - paygo_u))) + { + details.push(detail); + } + + Ok(ParsedUsage { usage, details }) } fn percent_of(used: f64, limit: f64) -> f64 { @@ -281,9 +334,11 @@ mod tests { let data: Value = serde_json::from_str(json).unwrap(); let result = parse_usage(&data).unwrap(); - assert!(result.primary.is_some()); - assert!(result.secondary.is_some()); - assert_eq!(result.plan, Some("Pro".to_string())); + assert_eq!(result.usage.primary.used_percent, 50.0); + assert!(result.usage.secondary.is_some()); + assert_eq!(result.usage.secondary_label, Some("API key".to_string())); + assert_eq!(result.usage.login_method, Some("Pro".to_string())); + assert!(!result.details.is_empty()); } #[test] @@ -302,9 +357,14 @@ mod tests { let data: Value = serde_json::from_str(json).unwrap(); let result = parse_usage(&data).unwrap(); - assert!(result.primary.is_some()); - assert!(result.secondary.is_none()); - assert!(result.plan.is_none()); + assert_eq!(result.usage.primary.used_percent, 50.0); + assert!(result.usage.secondary.is_none()); + assert!(result.usage.login_method.is_none()); + assert!(!result.details.is_empty()); + // Should have account-plan and api-key details with "Unlimited" label + let api_key_detail = result.details.iter().find(|d| d.id() == "api-key"); + assert!(api_key_detail.is_some()); + assert!(api_key_detail.unwrap().value().contains("Unlimited")); } #[test] @@ -334,9 +394,33 @@ mod tests { let data: Value = serde_json::from_str(json).unwrap(); let result = parse_usage(&data).unwrap(); - assert!(result.primary.is_some()); - assert!(result.secondary.is_some()); - assert_eq!(result.plan, Some("Pro".to_string())); + assert_eq!(result.usage.primary.used_percent, 50.0); + assert!(result.usage.secondary.is_some()); + assert_eq!(result.usage.login_method, Some("Pro".to_string())); + // Should have details for account-plan, api-key, and paygo + assert_eq!(result.details.len(), 3); + let paygo_detail = result.details.iter().find(|d| d.id() == "paygo"); + assert!(paygo_detail.is_some()); + } + + #[test] + fn test_parse_rejects_partial_paygo() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 100, + "paygo_usage": 10 + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data); + + assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Parse(_)))); } #[test] @@ -413,7 +497,7 @@ mod tests { let result = parse_usage(&data).unwrap(); // Trimmed empty string is rejected - assert!(result.plan.is_none()); + assert!(result.usage.login_method.is_none()); } #[test] @@ -436,6 +520,25 @@ mod tests { let result = parse_usage(&data).unwrap(); // Plan name > 120 chars is rejected - assert!(result.plan.is_none()); + assert!(result.usage.login_method.is_none()); + } + + #[test] + fn test_parse_rejects_zero_plan_limit() { + let json = r#"{ + "account": { + "plan_usage": 50, + "plan_limit": 0 + }, + "key": { + "usage": 25, + "limit": 200 + } + }"#; + let data: Value = serde_json::from_str(json).unwrap(); + let result = parse_usage(&data); + + assert!(result.is_err()); + assert!(matches!(result, Err(ProviderError::Parse(_)))); } } From e10682e444efb59ee48613e881a3ee5b705371ca Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 19:05:05 +0700 Subject: [PATCH 05/11] Fix Tavily parity: use generic HTTP errors and titled detail sections --- rust/src/providers/tavily/mod.rs | 121 +++++++++++++++++++------------ 1 file changed, 76 insertions(+), 45 deletions(-) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 1abd9041a0..360ae1ffb5 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -54,8 +54,8 @@ impl TavilyProvider { metadata: ProviderMetadata { id: ProviderId::Tavily, display_name: "Tavily", - session_label: "Account plan", - weekly_label: "Account plan", + session_label: "Plan credits", + weekly_label: "API key", supports_opus: false, supports_credits: false, default_enabled: false, @@ -93,25 +93,9 @@ impl TavilyProvider { let status = response.status(); match status { StatusCode::OK => {} - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => { - return Err(ProviderError::Other( - "Tavily API key is invalid or expired. Check your settings.".into(), - )); - } - StatusCode::TOO_MANY_REQUESTS => { - return Err(ProviderError::Other( - "Tavily rate limit exceeded. Keep refreshes at least one minute apart.".into(), - )); - } - status if status.is_server_error() => { - return Err(ProviderError::Other(format!( - "Tavily service error: HTTP {}.", - status.as_u16() - ))); - } _ => { return Err(ProviderError::Other(format!( - "Tavily API error: HTTP {}.", + "Tavily returned HTTP {}.", status.as_u16() ))); } @@ -263,44 +247,91 @@ fn parse_usage(payload: &Value) -> Result { // Build display details (used for Settings panel expandable section) let mut details = Vec::new(); - // Account plan detail + // Account plan section + if let Some(detail) = ProviderDisplayDetail::new( + "account-plan-used", + "Used", + format!("{} credits", plan_usage), + ) + .and_then(|d| d.with_section_title("Account plan")) + { + details.push(detail); + } + if let Some(detail) = ProviderDisplayDetail::new( + "account-plan-limit", + "Limit", + format!("{} credits", plan_limit), + ) + .and_then(|d| d.with_section_title("Account plan")) + { + details.push(detail); + } if let Some(detail) = ProviderDisplayDetail::new( - "account-plan", - "Account plan", - format!("{} / {}", plan_usage, plan_limit), + "account-plan-remaining", + "Remaining", + format!("{} credits", (plan_limit - plan_usage).max(0)), ) - .and_then(|d| d.with_secondary_value(format!("Remaining: {}", plan_limit - plan_usage))) + .and_then(|d| d.with_section_title("Account plan")) { details.push(detail); } - // API key detail + // API key section + if let Some(detail) = + ProviderDisplayDetail::new("api-key-used", "Used", format!("{} credits", key_usage)) + .and_then(|d| d.with_section_title("API key")) + { + details.push(detail); + } if let Some(limit) = key_limit { - let detail = - ProviderDisplayDetail::new("api-key", "API key", format!("{} / {}", key_usage, limit)) - .and_then(|d| d.with_secondary_value(format!("Remaining: {}", limit - key_usage))); - if let Some(d) = detail { - details.push(d); + if let Some(detail) = + ProviderDisplayDetail::new("api-key-limit", "Limit", format!("{} credits", limit)) + .and_then(|d| d.with_section_title("API key")) + { + details.push(detail); + } + if let Some(detail) = ProviderDisplayDetail::new( + "api-key-remaining", + "Remaining", + format!("{} credits", (limit - key_usage).max(0)), + ) + .and_then(|d| d.with_section_title("API key")) + { + details.push(detail); } } else { // Unlimited key if let Some(detail) = - ProviderDisplayDetail::new("api-key", "API key", format!("{} / Unlimited", key_usage)) + ProviderDisplayDetail::new("api-key-limit", "Limit", "Unlimited".to_string()) + .and_then(|d| d.with_section_title("API key")) { details.push(detail); } } - // Pay-as-you-go detail (if present) - if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) - && let Some(detail) = ProviderDisplayDetail::new( - "paygo", - "Pay as you go", - format!("{} / {}", paygo_u, paygo_l), + // Pay-as-you-go section (if present) + if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) { + if let Some(detail) = + ProviderDisplayDetail::new("paygo-used", "Used", format!("{} credits", paygo_u)) + .and_then(|d| d.with_section_title("Pay as you go")) + { + details.push(detail); + } + if let Some(detail) = + ProviderDisplayDetail::new("paygo-limit", "Limit", format!("{} credits", paygo_l)) + .and_then(|d| d.with_section_title("Pay as you go")) + { + details.push(detail); + } + if let Some(detail) = ProviderDisplayDetail::new( + "paygo-remaining", + "Remaining", + format!("{} credits", (paygo_l - paygo_u).max(0)), ) - .and_then(|d| d.with_secondary_value(format!("Remaining: {}", paygo_l - paygo_u))) - { - details.push(detail); + .and_then(|d| d.with_section_title("Pay as you go")) + { + details.push(detail); + } } Ok(ParsedUsage { usage, details }) @@ -361,8 +392,8 @@ mod tests { assert!(result.usage.secondary.is_none()); assert!(result.usage.login_method.is_none()); assert!(!result.details.is_empty()); - // Should have account-plan and api-key details with "Unlimited" label - let api_key_detail = result.details.iter().find(|d| d.id() == "api-key"); + // Should have api-key-limit detail with "Unlimited" label + let api_key_detail = result.details.iter().find(|d| d.id() == "api-key-limit"); assert!(api_key_detail.is_some()); assert!(api_key_detail.unwrap().value().contains("Unlimited")); } @@ -397,9 +428,9 @@ mod tests { assert_eq!(result.usage.primary.used_percent, 50.0); assert!(result.usage.secondary.is_some()); assert_eq!(result.usage.login_method, Some("Pro".to_string())); - // Should have details for account-plan, api-key, and paygo - assert_eq!(result.details.len(), 3); - let paygo_detail = result.details.iter().find(|d| d.id() == "paygo"); + // Should have details for account-plan (3 rows), api-key (3 rows), and paygo (3 rows) = 9 total + assert_eq!(result.details.len(), 9); + let paygo_detail = result.details.iter().find(|d| d.id() == "paygo-used"); assert!(paygo_detail.is_some()); } From ce6ff7de9cff0be1f22633db25faa3fe4dffdd4e Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 19:21:50 +0700 Subject: [PATCH 06/11] Fix Tavily number formatting: use thousands separators --- rust/src/providers/tavily/mod.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 360ae1ffb5..993d5d195f 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -15,6 +15,7 @@ use crate::core::{ ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::read_bounded_response; +use super::format; const USAGE_URL: &str = "https://api.tavily.com/usage"; const CREDENTIAL_TARGET: &str = "codexbar-tavily"; @@ -251,7 +252,7 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "account-plan-used", "Used", - format!("{} credits", plan_usage), + format!("{} credits", format::number(plan_usage as f64, 0)), ) .and_then(|d| d.with_section_title("Account plan")) { @@ -260,7 +261,7 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "account-plan-limit", "Limit", - format!("{} credits", plan_limit), + format!("{} credits", format::number(plan_limit as f64, 0)), ) .and_then(|d| d.with_section_title("Account plan")) { @@ -269,7 +270,7 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "account-plan-remaining", "Remaining", - format!("{} credits", (plan_limit - plan_usage).max(0)), + format!("{} credits", format::number((plan_limit - plan_usage).max(0) as f64, 0)), ) .and_then(|d| d.with_section_title("Account plan")) { @@ -278,14 +279,14 @@ fn parse_usage(payload: &Value) -> Result { // API key section if let Some(detail) = - ProviderDisplayDetail::new("api-key-used", "Used", format!("{} credits", key_usage)) + ProviderDisplayDetail::new("api-key-used", "Used", format!("{} credits", format::number(key_usage as f64, 0))) .and_then(|d| d.with_section_title("API key")) { details.push(detail); } if let Some(limit) = key_limit { if let Some(detail) = - ProviderDisplayDetail::new("api-key-limit", "Limit", format!("{} credits", limit)) + ProviderDisplayDetail::new("api-key-limit", "Limit", format!("{} credits", format::number(limit as f64, 0))) .and_then(|d| d.with_section_title("API key")) { details.push(detail); @@ -293,7 +294,7 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "api-key-remaining", "Remaining", - format!("{} credits", (limit - key_usage).max(0)), + format!("{} credits", format::number((limit - key_usage).max(0) as f64, 0)), ) .and_then(|d| d.with_section_title("API key")) { @@ -312,13 +313,13 @@ fn parse_usage(payload: &Value) -> Result { // Pay-as-you-go section (if present) if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) { if let Some(detail) = - ProviderDisplayDetail::new("paygo-used", "Used", format!("{} credits", paygo_u)) + ProviderDisplayDetail::new("paygo-used", "Used", format!("{} credits", format::number(paygo_u as f64, 0))) .and_then(|d| d.with_section_title("Pay as you go")) { details.push(detail); } if let Some(detail) = - ProviderDisplayDetail::new("paygo-limit", "Limit", format!("{} credits", paygo_l)) + ProviderDisplayDetail::new("paygo-limit", "Limit", format!("{} credits", format::number(paygo_l as f64, 0))) .and_then(|d| d.with_section_title("Pay as you go")) { details.push(detail); @@ -326,7 +327,7 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "paygo-remaining", "Remaining", - format!("{} credits", (paygo_l - paygo_u).max(0)), + format!("{} credits", format::number((paygo_l - paygo_u).max(0) as f64, 0)), ) .and_then(|d| d.with_section_title("Pay as you go")) { From 4650d4c22cfe5b19c12b97bbd48c6e744383bac7 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 19:23:23 +0700 Subject: [PATCH 07/11] Format code --- rust/src/providers/tavily/mod.rs | 53 ++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 993d5d195f..913dacda63 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -10,12 +10,12 @@ use reqwest::{Client, StatusCode}; use serde::Deserialize; use serde_json::Value; +use super::format; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::read_bounded_response; -use super::format; const USAGE_URL: &str = "https://api.tavily.com/usage"; const CREDENTIAL_TARGET: &str = "codexbar-tavily"; @@ -270,7 +270,10 @@ fn parse_usage(payload: &Value) -> Result { if let Some(detail) = ProviderDisplayDetail::new( "account-plan-remaining", "Remaining", - format!("{} credits", format::number((plan_limit - plan_usage).max(0) as f64, 0)), + format!( + "{} credits", + format::number((plan_limit - plan_usage).max(0) as f64, 0) + ), ) .and_then(|d| d.with_section_title("Account plan")) { @@ -278,23 +281,32 @@ fn parse_usage(payload: &Value) -> Result { } // API key section - if let Some(detail) = - ProviderDisplayDetail::new("api-key-used", "Used", format!("{} credits", format::number(key_usage as f64, 0))) - .and_then(|d| d.with_section_title("API key")) + if let Some(detail) = ProviderDisplayDetail::new( + "api-key-used", + "Used", + format!("{} credits", format::number(key_usage as f64, 0)), + ) + .and_then(|d| d.with_section_title("API key")) { details.push(detail); } if let Some(limit) = key_limit { - if let Some(detail) = - ProviderDisplayDetail::new("api-key-limit", "Limit", format!("{} credits", format::number(limit as f64, 0))) - .and_then(|d| d.with_section_title("API key")) + if let Some(detail) = ProviderDisplayDetail::new( + "api-key-limit", + "Limit", + format!("{} credits", format::number(limit as f64, 0)), + ) + .and_then(|d| d.with_section_title("API key")) { details.push(detail); } if let Some(detail) = ProviderDisplayDetail::new( "api-key-remaining", "Remaining", - format!("{} credits", format::number((limit - key_usage).max(0) as f64, 0)), + format!( + "{} credits", + format::number((limit - key_usage).max(0) as f64, 0) + ), ) .and_then(|d| d.with_section_title("API key")) { @@ -312,22 +324,31 @@ fn parse_usage(payload: &Value) -> Result { // Pay-as-you-go section (if present) if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) { - if let Some(detail) = - ProviderDisplayDetail::new("paygo-used", "Used", format!("{} credits", format::number(paygo_u as f64, 0))) - .and_then(|d| d.with_section_title("Pay as you go")) + if let Some(detail) = ProviderDisplayDetail::new( + "paygo-used", + "Used", + format!("{} credits", format::number(paygo_u as f64, 0)), + ) + .and_then(|d| d.with_section_title("Pay as you go")) { details.push(detail); } - if let Some(detail) = - ProviderDisplayDetail::new("paygo-limit", "Limit", format!("{} credits", format::number(paygo_l as f64, 0))) - .and_then(|d| d.with_section_title("Pay as you go")) + if let Some(detail) = ProviderDisplayDetail::new( + "paygo-limit", + "Limit", + format!("{} credits", format::number(paygo_l as f64, 0)), + ) + .and_then(|d| d.with_section_title("Pay as you go")) { details.push(detail); } if let Some(detail) = ProviderDisplayDetail::new( "paygo-remaining", "Remaining", - format!("{} credits", format::number((paygo_l - paygo_u).max(0) as f64, 0)), + format!( + "{} credits", + format::number((paygo_l - paygo_u).max(0) as f64, 0) + ), ) .and_then(|d| d.with_section_title("Pay as you go")) { From 54dfd5fb4dff30246f25497f5f6b05400ff827fa Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 20:48:12 +0700 Subject: [PATCH 08/11] Match Tavily credit parsing to upstream --- rust/src/providers/tavily/mod.rs | 650 ++++++++++++------------------- 1 file changed, 247 insertions(+), 403 deletions(-) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 913dacda63..9a22001422 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -7,7 +7,6 @@ use async_trait::async_trait; use reqwest::{Client, StatusCode}; -use serde::Deserialize; use serde_json::Value; use super::format; @@ -23,27 +22,6 @@ pub const API_KEY_ENV: &str = "TAVILY_API_KEY"; const MAX_RESPONSE_BYTES: usize = 256 * 1024; const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -#[derive(Debug, Deserialize)] -struct UsageResponse { - account: Option, - key: Option, -} - -#[derive(Debug, Deserialize)] -struct AccountInfo { - plan_usage: Option, - plan_limit: Option, - paygo_usage: Option, - paygo_limit: Option, - current_plan: Option, -} - -#[derive(Debug, Deserialize)] -struct KeyInfo { - usage: Option, - limit: Option, -} - pub struct TavilyProvider { metadata: ProviderMetadata, client: Client, @@ -106,8 +84,7 @@ impl TavilyProvider { .await .map_err(|_| ProviderError::Parse("Failed to read Tavily response.".into()))?; - let data: Value = serde_json::from_slice(&body) - .map_err(|_| ProviderError::Parse("Tavily response is not valid JSON.".into()))?; + let data: Value = serde_json::from_slice(&body).map_err(|_| unrecognized())?; let parsed = parse_usage(&data)?; let usage = parsed.usage; @@ -155,205 +132,135 @@ struct ParsedUsage { details: Vec, } -fn parse_usage(payload: &Value) -> Result { - let data = payload - .as_object() - .ok_or_else(|| ProviderError::Parse("Tavily response is not an object.".into()))?; +const UNRECOGNIZED: &str = "Tavily returned an unrecognized credit usage response."; - let account = data - .get("account") - .and_then(|v| v.as_object()) - .ok_or_else(|| ProviderError::Parse("Tavily response missing account data.".into()))?; - - let key = data - .get("key") - .and_then(|v| v.as_object()) - .ok_or_else(|| ProviderError::Parse("Tavily response missing key data.".into()))?; - - // Extract account plan info - let plan_usage = account - .get("plan_usage") - .and_then(|v| v.as_i64()) - .ok_or_else(|| ProviderError::Parse("Invalid plan_usage value.".into()))?; - - let plan_limit = account - .get("plan_limit") - .and_then(|v| v.as_i64()) - .ok_or_else(|| ProviderError::Parse("Invalid plan_limit value.".into()))?; - - // Extract API key info - let key_usage = key - .get("usage") - .and_then(|v| v.as_i64()) - .ok_or_else(|| ProviderError::Parse("Invalid key usage value.".into()))?; - - let key_limit = key.get("limit").and_then(|v| v.as_i64()); - - // Extract PAYG info (optional) - let paygo_usage = account.get("paygo_usage").and_then(|v| v.as_i64()); - let paygo_limit = account.get("paygo_limit").and_then(|v| v.as_i64()); - - // Validate that if either PAYG field exists, both must be valid numbers - if (paygo_usage.is_some() || paygo_limit.is_some()) - && !(paygo_usage.is_some() && paygo_limit.is_some()) - { - return Err(ProviderError::Parse( - "Tavily pay-as-you-go usage and limit must both be present together.".into(), - )); - } - - // Extract plan name (optional) - let plan = account - .get("current_plan") - .and_then(|v| v.as_str()) - .and_then(|s| { - let trimmed = s.trim(); - if trimmed.is_empty() || trimmed.len() > 120 { - None - } else { - Some(trimmed.to_string()) - } - }); +fn unrecognized() -> ProviderError { + ProviderError::Other(UNRECOGNIZED.into()) +} - // Build primary (account plan) window - let primary = if plan_limit > 0 { - RateWindow::new(percent_of(plan_usage as f64, plan_limit as f64)) - } else { - return Err(ProviderError::Parse("Plan limit must be positive.".into())); +/// Upstream `count`: a non-negative safe integer. +fn count(value: Option<&Value>) -> Result { + const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + let Some(Value::Number(n)) = value else { + return Err(unrecognized()); }; - - // Build secondary (API key) window - null limit means unlimited - let secondary = if let Some(limit) = key_limit { - if limit > 0 { - Some(RateWindow::new(percent_of(key_usage as f64, limit as f64))) + if let Some(v) = n.as_u64() { + return if v <= MAX_SAFE_INTEGER { + Ok(v) } else { - None - } - } else { - // Null key limit = unlimited, no quota window displayed - None - }; - - // Build usage snapshot - let mut usage = UsageSnapshot::new(primary); - if let Some(secondary) = secondary { - usage = usage - .with_secondary(secondary) - .with_secondary_label("API key"); + Err(unrecognized()) + }; } - if let Some(plan_name) = plan { - usage = usage.with_login_method(plan_name); + match n.as_f64() { + // JSON like `5.0` is still a safe integer to JavaScript. + Some(f) if f.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER as f64).contains(&f) => { + // `+ 0.0` turns -0 into 0. + format!("{:.0}", f + 0.0) + .parse() + .map_err(|_| unrecognized()) + } + _ => Err(unrecognized()), } +} - // Build display details (used for Settings panel expandable section) - let mut details = Vec::new(); +fn credits(value: u64) -> String { + format!("{} credits", format::number(value as f64, 0)) +} - // Account plan section - if let Some(detail) = ProviderDisplayDetail::new( - "account-plan-used", - "Used", - format!("{} credits", format::number(plan_usage as f64, 0)), - ) - .and_then(|d| d.with_section_title("Account plan")) - { - details.push(detail); - } - if let Some(detail) = ProviderDisplayDetail::new( - "account-plan-limit", - "Limit", - format!("{} credits", format::number(plan_limit as f64, 0)), - ) - .and_then(|d| d.with_section_title("Account plan")) - { - details.push(detail); - } - if let Some(detail) = ProviderDisplayDetail::new( - "account-plan-remaining", - "Remaining", - format!( - "{} credits", - format::number((plan_limit - plan_usage).max(0) as f64, 0) +/// One titled section: Used, Limit ("Unlimited" when `cap` is None), and +/// Remaining only for a finite cap. +fn section( + details: &mut Vec, + id: &str, + title: &str, + usage: u64, + cap: Option, +) { + let mut rows = vec![ + ("used", "Used", credits(usage)), + ( + "limit", + "Limit", + cap.map_or_else(|| "Unlimited".to_string(), credits), ), - ) - .and_then(|d| d.with_section_title("Account plan")) - { - details.push(detail); - } - - // API key section - if let Some(detail) = ProviderDisplayDetail::new( - "api-key-used", - "Used", - format!("{} credits", format::number(key_usage as f64, 0)), - ) - .and_then(|d| d.with_section_title("API key")) - { - details.push(detail); + ]; + if let Some(cap) = cap { + rows.push(("remaining", "Remaining", credits(cap.saturating_sub(usage)))); } - if let Some(limit) = key_limit { - if let Some(detail) = ProviderDisplayDetail::new( - "api-key-limit", - "Limit", - format!("{} credits", format::number(limit as f64, 0)), - ) - .and_then(|d| d.with_section_title("API key")) - { - details.push(detail); - } - if let Some(detail) = ProviderDisplayDetail::new( - "api-key-remaining", - "Remaining", - format!( - "{} credits", - format::number((limit - key_usage).max(0) as f64, 0) - ), - ) - .and_then(|d| d.with_section_title("API key")) - { - details.push(detail); - } - } else { - // Unlimited key - if let Some(detail) = - ProviderDisplayDetail::new("api-key-limit", "Limit", "Unlimited".to_string()) - .and_then(|d| d.with_section_title("API key")) + for (suffix, label, value) in rows { + if let Some(detail) = ProviderDisplayDetail::new(format!("{id}-{suffix}"), label, value) + .and_then(|d| d.with_section_title(title)) { details.push(detail); } } +} - // Pay-as-you-go section (if present) - if let (Some(paygo_u), Some(paygo_l)) = (paygo_usage, paygo_limit) { - if let Some(detail) = ProviderDisplayDetail::new( - "paygo-used", - "Used", - format!("{} credits", format::number(paygo_u as f64, 0)), - ) - .and_then(|d| d.with_section_title("Pay as you go")) - { - details.push(detail); - } - if let Some(detail) = ProviderDisplayDetail::new( - "paygo-limit", - "Limit", - format!("{} credits", format::number(paygo_l as f64, 0)), - ) - .and_then(|d| d.with_section_title("Pay as you go")) - { - details.push(detail); - } - if let Some(detail) = ProviderDisplayDetail::new( - "paygo-remaining", - "Remaining", - format!( - "{} credits", - format::number((paygo_l - paygo_u).max(0) as f64, 0) - ), - ) - .and_then(|d| d.with_section_title("Pay as you go")) +fn parse_usage(payload: &Value) -> Result { + let data = payload.as_object().ok_or_else(unrecognized)?; + let account = data + .get("account") + .and_then(Value::as_object) + .ok_or_else(unrecognized)?; + let key = data + .get("key") + .and_then(Value::as_object) + .ok_or_else(unrecognized)?; + + let used = count(account.get("plan_usage"))?; + let limit = count(account.get("plan_limit"))?; + let key_used = count(key.get("usage"))?; + let key_limit = match key.get("limit") { + Some(Value::Null) => None, + other => Some(count(other)?), + }; + + let mut details = Vec::new(); + section( + &mut details, + "account-plan", + "Account plan", + used, + Some(limit), + ); + section(&mut details, "api-key", "API key", key_used, key_limit); + if account.contains_key("paygo_usage") || account.contains_key("paygo_limit") { + let paygo_used = count(account.get("paygo_usage"))?; + let paygo_limit = count(account.get("paygo_limit"))?; + section( + &mut details, + "paygo", + "Pay as you go", + paygo_used, + Some(paygo_limit), + ); + } + + // JavaScript `plan.length` counts UTF-16 units of the untrimmed string. + let login_method = match account.get("current_plan") { + None => "API key".to_string(), + Some(Value::String(plan)) + if !plan.trim().is_empty() && plan.encode_utf16().count() <= 120 => { - details.push(detail); + plan.trim().to_string() } + Some(_) => return Err(unrecognized()), + }; + + // Upstream omits a window whose limit is zero; the card then draws no bar. + let primary = if limit > 0 { + RateWindow::new(percent_of(used as f64, limit as f64)) + } else { + RateWindow::informational("Account plan").hidden_on_card() + }; + let mut usage = UsageSnapshot::new(primary).with_login_method(login_method); + if let Some(key_limit) = key_limit.filter(|limit| *limit > 0) { + usage = usage + .with_secondary(RateWindow::new(percent_of( + key_used as f64, + key_limit as f64, + ))) + .with_secondary_label("API key"); } Ok(ParsedUsage { usage, details }) @@ -370,228 +277,165 @@ fn percent_of(used: f64, limit: f64) -> f64 { #[cfg(test)] mod tests { use super::*; - - #[test] - fn test_parse_valid_usage() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100, - "current_plan": "Pro" - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data).unwrap(); - - assert_eq!(result.usage.primary.used_percent, 50.0); - assert!(result.usage.secondary.is_some()); - assert_eq!(result.usage.secondary_label, Some("API key".to_string())); - assert_eq!(result.usage.login_method, Some("Pro".to_string())); - assert!(!result.details.is_empty()); + use serde_json::json; + + fn rows(result: &ParsedUsage) -> Vec<(String, String, String)> { + result + .details + .iter() + .map(|d| { + ( + d.section_title().unwrap_or_default().to_string(), + d.title().to_string(), + d.value().to_string(), + ) + }) + .collect() } - #[test] - fn test_parse_unlimited_key() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100, - "current_plan": null - }, - "key": { - "usage": 25, - "limit": null - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data).unwrap(); - - assert_eq!(result.usage.primary.used_percent, 50.0); - assert!(result.usage.secondary.is_none()); - assert!(result.usage.login_method.is_none()); - assert!(!result.details.is_empty()); - // Should have api-key-limit detail with "Unlimited" label - let api_key_detail = result.details.iter().find(|d| d.id() == "api-key-limit"); - assert!(api_key_detail.is_some()); - assert!(api_key_detail.unwrap().value().contains("Unlimited")); - } - - #[test] - fn test_percent_of() { - assert_eq!(percent_of(50.0, 100.0), 50.0); - assert_eq!(percent_of(0.0, 100.0), 0.0); - assert_eq!(percent_of(100.0, 100.0), 100.0); - assert_eq!(percent_of(1.0, 100.0), 1.0); - assert_eq!(percent_of(50.0, 0.0), 0.0); + fn row(section: &str, label: &str, value: &str) -> (String, String, String) { + (section.to_string(), label.to_string(), value.to_string()) } - #[test] - fn test_parse_with_paygo() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100, - "paygo_usage": 10, - "paygo_limit": 50, - "current_plan": "Pro" - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data).unwrap(); - - assert_eq!(result.usage.primary.used_percent, 50.0); - assert!(result.usage.secondary.is_some()); - assert_eq!(result.usage.login_method, Some("Pro".to_string())); - // Should have details for account-plan (3 rows), api-key (3 rows), and paygo (3 rows) = 9 total - assert_eq!(result.details.len(), 9); - let paygo_detail = result.details.iter().find(|d| d.id() == "paygo-used"); - assert!(paygo_detail.is_some()); + fn payload(account: Value, key: Value) -> Value { + json!({ "account": account, "key": key }) } - #[test] - fn test_parse_rejects_partial_paygo() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100, - "paygo_usage": 10 - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data); - - assert!(result.is_err()); - assert!(matches!(result, Err(ProviderError::Parse(_)))); - } - - #[test] - fn test_parse_rejects_missing_account() { - let json = r#"{ - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data); - - assert!(result.is_err()); - assert!(matches!(result, Err(ProviderError::Parse(_)))); + fn assert_unrecognized(result: Result) { + match result { + Err(ProviderError::Other(message)) => assert_eq!(message, UNRECOGNIZED), + other => panic!("expected unrecognized response, got {other:?}"), + } } #[test] - fn test_parse_rejects_missing_key() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data); - - assert!(result.is_err()); - assert!(matches!(result, Err(ProviderError::Parse(_)))); + fn full_payload_matches_upstream_sections() { + let result = parse_usage(&payload( + json!({ "plan_usage": 1200, "plan_limit": 4000, "paygo_usage": 10, "paygo_limit": 50, "current_plan": " Pro " }), + json!({ "usage": 800, "limit": 2000 }), + )) + .unwrap(); + + assert_eq!(result.usage.primary.used_percent, 30.0); + assert!(!result.usage.primary.hidden_on_card); + assert_eq!( + result.usage.secondary.as_ref().map(|w| w.used_percent), + Some(40.0) + ); + assert_eq!(result.usage.secondary_label.as_deref(), Some("API key")); + assert_eq!(result.usage.login_method.as_deref(), Some("Pro")); + assert_eq!( + rows(&result), + [ + row("Account plan", "Used", "1,200 credits"), + row("Account plan", "Limit", "4,000 credits"), + row("Account plan", "Remaining", "2,800 credits"), + row("API key", "Used", "800 credits"), + row("API key", "Limit", "2,000 credits"), + row("API key", "Remaining", "1,200 credits"), + row("Pay as you go", "Used", "10 credits"), + row("Pay as you go", "Limit", "50 credits"), + row("Pay as you go", "Remaining", "40 credits"), + ] + ); } #[test] - fn test_parse_rejects_non_object() { - let data: Value = serde_json::json!([1, 2, 3]); - let result = parse_usage(&data); + fn unlimited_key_has_no_secondary_and_no_remaining_row() { + let result = parse_usage(&payload( + json!({ "plan_usage": 5, "plan_limit": 10 }), + json!({ "usage": 3, "limit": null }), + )) + .unwrap(); - assert!(result.is_err()); - assert!(matches!(result, Err(ProviderError::Parse(_)))); + assert!(result.usage.secondary.is_none()); + assert_eq!(result.usage.login_method.as_deref(), Some("API key")); + assert_eq!( + rows(&result)[3..], + [ + row("API key", "Used", "3 credits"), + row("API key", "Limit", "Unlimited") + ] + ); } #[test] - fn test_parse_rejects_invalid_plan_usage() { - let json = r#"{ - "account": { - "plan_usage": "fifty", - "plan_limit": 100 - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data); - - assert!(result.is_err()); + fn zero_limits_keep_the_sections_but_draw_no_bars() { + let result = parse_usage(&payload( + json!({ "plan_usage": 7, "plan_limit": 0 }), + json!({ "usage": 2, "limit": 0 }), + )) + .unwrap(); + + assert!(result.usage.primary.hidden_on_card); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.is_none()); + assert_eq!( + rows(&result)[2], + row("Account plan", "Remaining", "0 credits") + ); } #[test] - fn test_parse_empty_plan_name() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 100, - "current_plan": " " - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data).unwrap(); - - // Trimmed empty string is rejected - assert!(result.usage.login_method.is_none()); + fn rejects_shapes_upstream_rejects() { + let ok_account = json!({ "plan_usage": 1, "plan_limit": 2 }); + let ok_key = json!({ "usage": 1, "limit": 2 }); + assert_unrecognized(parse_usage(&json!([1, 2]))); + assert_unrecognized(parse_usage(&json!({ "key": ok_key }))); + assert_unrecognized(parse_usage(&json!({ "account": ok_account }))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": -1, "plan_limit": 2 }), + ok_key.clone(), + ))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": 1.5, "plan_limit": 2 }), + ok_key.clone(), + ))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": "1", "plan_limit": 2 }), + ok_key.clone(), + ))); + assert_unrecognized(parse_usage(&payload( + ok_account.clone(), + json!({ "usage": 1 }), + ))); + assert_unrecognized(parse_usage(&payload( + ok_account.clone(), + json!({ "usage": 1, "limit": "2" }), + ))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": 1, "plan_limit": 2, "paygo_usage": 1 }), + ok_key.clone(), + ))); } #[test] - fn test_parse_oversized_plan_name() { - let json = format!( - r#"{{ - "account": {{ - "plan_usage": 50, - "plan_limit": 100, - "current_plan": "{}" - }}, - "key": {{ - "usage": 25, - "limit": 200 - }} - }}"#, - "x".repeat(121) + fn plan_name_must_be_a_short_non_blank_string_when_present() { + let key = json!({ "usage": 1, "limit": 2 }); + let with_plan = |plan: Value| { + parse_usage(&payload( + json!({ "plan_usage": 1, "plan_limit": 2, "current_plan": plan }), + key.clone(), + )) + }; + assert_unrecognized(with_plan(json!(null))); + assert_unrecognized(with_plan(json!(" "))); + assert_unrecognized(with_plan(json!(5))); + assert_unrecognized(with_plan(json!("x".repeat(121)))); + assert_eq!( + with_plan(json!("x".repeat(120))) + .unwrap() + .usage + .login_method + .map(|m| m.len()), + Some(120) ); - let data: Value = serde_json::from_str(&json).unwrap(); - let result = parse_usage(&data).unwrap(); - - // Plan name > 120 chars is rejected - assert!(result.usage.login_method.is_none()); } #[test] - fn test_parse_rejects_zero_plan_limit() { - let json = r#"{ - "account": { - "plan_usage": 50, - "plan_limit": 0 - }, - "key": { - "usage": 25, - "limit": 200 - } - }"#; - let data: Value = serde_json::from_str(json).unwrap(); - let result = parse_usage(&data); - - assert!(result.is_err()); - assert!(matches!(result, Err(ProviderError::Parse(_)))); + fn percent_is_clamped() { + assert_eq!(percent_of(50.0, 100.0), 50.0); + assert_eq!(percent_of(150.0, 100.0), 100.0); + assert_eq!(percent_of(50.0, 0.0), 0.0); } } From 470dffc3c2573c0b5c982e4cab91ceac68da4ed1 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Mon, 12 Oct 2026 20:31:16 +0700 Subject: [PATCH 09/11] Skip card-hidden windows in the provider settings pane --- .../src-tauri/src/commands/tests.rs | 42 +++++++++++++++++++ .../src-tauri/src/commands/usage_items.rs | 40 +++++++++++++----- .../providers/sections/UsageSection.test.tsx | 16 +++++++ .../providers/sections/UsageSection.tsx | 9 ++-- 4 files changed, 94 insertions(+), 13 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index 587da2344e..bae262dc8e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -1437,6 +1437,48 @@ fn usage_item_descriptors_include_detail_sections() { assert_eq!(hidden.title, "quota"); } +#[test] +fn usage_item_descriptors_skip_card_hidden_windows() { + let metadata = instantiate_provider(ProviderId::Codex).metadata().clone(); + let result = ProviderFetchResult { + usage: codexbar::core::UsageSnapshot::new( + codexbar::core::RateWindow::informational("$1.00").hidden_on_card(), + ) + .with_secondary(codexbar::core::RateWindow::new(20.0)), + cost: None, + wayfinder_usage: None, + inventory: Vec::new(), + reset_credits: None, + display_details: vec![ + codexbar::core::ProviderDisplayDetail::new("spend", "Spend", "$1.00") + .expect("valid detail"), + ], + source_label: "api".to_string(), + has_successful_claude_cli_quota: false, + pace_authoritative: true, + account_identity: None, + open_ai_api_usage: None, + last_good_owner: None, + }; + let mut snapshot = + ProviderUsageSnapshot::from_fetch_result(ProviderId::Codex, &metadata, &result, None, None); + let mut hidden_extra = snapshot.primary.clone(); + hidden_extra.hidden_on_card = true; + snapshot.extra_rate_windows = vec![NamedRateWindowSnapshot { + id: "hidden".to_string(), + title: "Hidden".to_string(), + window: hidden_extra, + fallback_lane: false, + icon_fallback: None, + }]; + + let items = + super::usage_item_descriptors(Some(&snapshot), &Settings::default(), ProviderId::Codex); + let ids: Vec<&str> = items.iter().map(|item| item.id.as_str()).collect(); + + assert_eq!(ids, ["detailSection:Spend", "metric:secondary"]); +} + #[test] fn pace_stage_serializes_to_snake_case_string() { use codexbar::core::PaceStage; diff --git a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs index e60e8c16ea..5b176d2a39 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/usage_items.rs @@ -150,13 +150,21 @@ pub(crate) fn usage_item_descriptors( }; if let Some(snapshot) = snapshot { - let session = locale::get_text(lang, LocaleKey::ProviderSessionLabel); - push( - "primary", - snapshot.primary_label.as_deref().unwrap_or(&session), - true, - ); - if snapshot.secondary.is_some() { + // A card-hidden window is never drawn as a row, so a visibility + // toggle for it would control nothing. + if !snapshot.primary.hidden_on_card { + let session = locale::get_text(lang, LocaleKey::ProviderSessionLabel); + push( + "primary", + snapshot.primary_label.as_deref().unwrap_or(&session), + true, + ); + } + if snapshot + .secondary + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let weekly = locale::get_text(lang, LocaleKey::ProviderWeeklyLabel); push( "secondary", @@ -164,11 +172,19 @@ pub(crate) fn usage_item_descriptors( true, ); } - if snapshot.model_specific.is_some() { + if snapshot + .model_specific + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let model = locale::get_text(lang, LocaleKey::DetailWindowModelSpecific); push("model-specific", &model, true); } - if snapshot.tertiary.is_some() { + if snapshot + .tertiary + .as_ref() + .is_some_and(|w| !w.hidden_on_card) + { let tertiary = locale::get_text(lang, LocaleKey::DetailWindowTertiary); push( "tertiary", @@ -176,7 +192,11 @@ pub(crate) fn usage_item_descriptors( true, ); } - for extra in &snapshot.extra_rate_windows { + for extra in snapshot + .extra_rate_windows + .iter() + .filter(|extra| !extra.window.hidden_on_card) + { push(&format!("extra-{}", extra.id), &extra.title, true); } } diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index 85f30d2f2d..4f226a5303 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -96,6 +96,22 @@ describe("UsageSection", () => { expect(screen.getByText("58% left")).toBeInTheDocument(); }); + it("omits card-hidden windows, as the tray card does", async () => { + const detail = provider(); + detail.session = { ...rateWindow(0), hiddenOnCard: true }; + detail.primaryLabel = "Spend"; + + render( + + key} /> + , + ); + + expect(await screen.findByText("Additional Budget")).toBeInTheDocument(); + expect(screen.queryByText("Spend")).not.toBeInTheDocument(); + expect(screen.queryByText("100% left")).not.toBeInTheDocument(); + }); + it.each([false, true])( "renders detail-backed amounts as a detail line, not reset text (resetsAt: %s)", async (hasReset) => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index 68ca40cf33..399910ba20 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -88,14 +88,17 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor }); } + // Card-hidden windows only feed the tray icon and float bar, as on MenuCard. + const shownBars = bars.filter((bar) => !bar.rate.hiddenOnCard); + // Cached snapshots outlive the pool reset; re-check blocks on the clock. const monthlyLimitBlockNow = useMonthlyLimitBlockNow( - bars.map((bar) => bar.rate.monthlyLimitBlock), + shownBars.map((bar) => bar.rate.monthlyLimitBlock), ); const inventory = provider.inventory ?? []; const displayDetails = provider.displayDetails ?? []; const displayDetailGroups = groupProviderDisplayDetails(displayDetails); - if (bars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { + if (shownBars.length === 0 && inventory.length === 0 && displayDetails.length === 0) { return null; } @@ -105,7 +108,7 @@ export function UsageSection({ provider, resetTimeRelative, display, accentColor style={accentColor ? ({ "--provider-accent": accentColor } as CSSProperties) : undefined} >

{t("ProviderUsage")}

- {bars.map((b) => ( + {shownBars.map((b) => ( Date: Tue, 13 Oct 2026 09:22:46 +0700 Subject: [PATCH 10/11] Address Tavily review feedback Fail closed when the HTTP client cannot be built, and add local HTTP fixtures for 200, 429, and redirect responses. --- docs/PROVIDERS.md | 5 +- rust/src/providers/tavily/mod.rs | 213 ++++++++++++++++++++++++++++--- 2 files changed, 198 insertions(+), 20 deletions(-) diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 1d82ee88ca..9e9accf388 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -319,9 +319,8 @@ Configure the API key in Settings → Providers → Tavily (or `TAVILY_API_KEY`) `account.paygo_limit`), appear in a separate detail section. - The plan name (`account.current_plan`) identifies the account plan. No email or account ID is inferred; unknown plans do not create a phantom identity. -- 401 and 403 report "Tavily API key is invalid or expired. Check your - settings."; 429 reports rate limiting with guidance on request frequency. - Response bodies are never echoed in errors. +- Non-200 responses report "Tavily returned HTTP ."; response bodies + are never echoed in errors. - Tavily limits the usage endpoint to 10 requests per 10 minutes. Keep refreshes at least one minute apart and avoid repeated manual refreshes. A throttled request fails as rate-limited without automatic replay. diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index 9a22001422..f4099176b5 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -6,13 +6,13 @@ //! Ported from upstream CodexBar v0.74.1 (`Resources/Plugins/tavily.js`). use async_trait::async_trait; -use reqwest::{Client, StatusCode}; +use reqwest::Client; use serde_json::Value; use super::format; use crate::core::{ FetchContext, Provider, ProviderDisplayDetail, ProviderError, ProviderFetchResult, ProviderId, - ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, + ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, UsageSnapshot, }; use crate::providers::read_bounded_response; @@ -24,12 +24,18 @@ const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); pub struct TavilyProvider { metadata: ProviderMetadata, - client: Client, + client: Option, + usage_url: String, } impl TavilyProvider { pub fn new() -> Self { + Self::with_usage_url(USAGE_URL) + } + + fn with_usage_url(usage_url: &str) -> Self { Self { + usage_url: usage_url.to_owned(), metadata: ProviderMetadata { id: ProviderId::Tavily, display_name: "Tavily", @@ -47,7 +53,7 @@ impl TavilyProvider { .redirect(reqwest::redirect::Policy::none()) .timeout(REQUEST_TIMEOUT) .build() - .unwrap_or_else(|_| Client::new()), + .ok(), } } @@ -55,30 +61,23 @@ impl TavilyProvider { &self, ctx: &FetchContext, ) -> Result { + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure Tavily HTTP client.".into()) + })?; let key = crate::providers::resolve_api_key( ctx.api_key.as_deref(), CREDENTIAL_TARGET, &[API_KEY_ENV], )?; - let response = self - .client - .get(USAGE_URL) + let response = client + .get(&self.usage_url) .bearer_auth(&key) .header(reqwest::header::ACCEPT, "application/json") .send() .await?; - let status = response.status(); - match status { - StatusCode::OK => {} - _ => { - return Err(ProviderError::Other(format!( - "Tavily returned HTTP {}.", - status.as_u16() - ))); - } - } + check_status(response.status().as_u16())?; let body = read_bounded_response(response, MAX_RESPONSE_BYTES) .await @@ -124,6 +123,47 @@ impl Provider for TavilyProvider { fn available_sources(&self) -> Vec { vec![SourceMode::Auto, SourceMode::OAuth] } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + match error { + ProviderError::Other(message) if *message == status_message(401) => { + ProviderStateKind::ExpiredSession + } + _ => error.state_kind(), + } + } +} + +/// Upstream `ctx.fail.*` category for a non-200 `/usage` response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StatusFailure { + AuthenticationExpired, + PermissionDenied, + RateLimited, + ProviderUnavailable, + ApiFailure, +} + +fn classify_status(status: u16) -> Option { + match status { + 200 => None, + 401 => Some(StatusFailure::AuthenticationExpired), + 403 => Some(StatusFailure::PermissionDenied), + 429 => Some(StatusFailure::RateLimited), + 500.. => Some(StatusFailure::ProviderUnavailable), + _ => Some(StatusFailure::ApiFailure), + } +} + +fn status_message(status: u16) -> String { + format!("Tavily returned HTTP {}.", status) +} + +fn check_status(status: u16) -> Result<(), ProviderError> { + match classify_status(status) { + None => Ok(()), + Some(_) => Err(ProviderError::Other(status_message(status))), + } } #[derive(Debug, Clone)] @@ -438,4 +478,143 @@ mod tests { assert_eq!(percent_of(150.0, 100.0), 100.0); assert_eq!(percent_of(50.0, 0.0), 0.0); } + + #[test] + fn status_classification_matches_upstream() { + use StatusFailure::*; + let cases = [ + (200, None), + (302, Some(ApiFailure)), + (400, Some(ApiFailure)), + (401, Some(AuthenticationExpired)), + (403, Some(PermissionDenied)), + (429, Some(RateLimited)), + (503, Some(ProviderUnavailable)), + ]; + for (status, expected) in cases { + assert_eq!(classify_status(status), expected, "status {status}"); + match check_status(status) { + Ok(()) => assert!(expected.is_none()), + Err(ProviderError::Other(message)) => { + assert_eq!(message, format!("Tavily returned HTTP {status}.")) + } + Err(other) => panic!("unexpected error {other:?}"), + } + } + } + + #[test] + fn only_http_401_maps_to_expired_session() { + let provider = TavilyProvider::new(); + let kind = |status| provider.error_state_kind(&check_status(status).unwrap_err()); + assert_eq!(kind(401), ProviderStateKind::ExpiredSession); + for status in [302, 400, 403, 429, 503] { + assert_eq!(kind(status), ProviderStateKind::Unknown, "status {status}"); + } + } + + /// Serves one canned response per connection and records each request's + /// path and Authorization header. + async fn serve( + responses: Vec<(u16, &'static str, &'static str)>, + ) -> ( + String, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let url = format!( + "http://127.0.0.1:{}/usage", + listener.local_addr().unwrap().port() + ); + let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&recorded); + let task = tokio::spawn(async move { + for (status, extra_headers, body) in responses { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut request = Vec::new(); + let mut chunk = [0u8; 1024]; + while !request.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(read) => request.extend_from_slice(&chunk[..read]), + } + } + let text = String::from_utf8_lossy(&request).to_string(); + let path = text + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + let auth = text + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("authorization") + .then(|| value.trim().to_string()) + }) + .unwrap_or_default(); + sink.lock().unwrap().push((path, auth)); + let response = format!( + "HTTP/1.1 {status} X\r\n{extra_headers}content-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .expect("write fixture response"); + stream.shutdown().await.expect("close fixture stream"); + } + }); + (url, recorded, task) + } + + fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some("tvly-test".into()), + ..FetchContext::default() + } + } + + #[tokio::test] + async fn a_200_response_is_parsed_with_bearer_auth() { + let (url, recorded, task) = serve(vec![( + 200, + "", + r#"{"account":{"plan_usage":1200,"plan_limit":4000,"current_plan":"Pro"},"key":{"usage":800,"limit":2000}}"#, + )]) + .await; + let provider = TavilyProvider::with_usage_url(&url); + let result = provider.fetch_usage_impl(&keyed_context()).await.unwrap(); + assert_eq!(result.usage.primary.used_percent, 30.0); + assert_eq!( + *recorded.lock().unwrap(), + vec![("/usage".to_string(), "Bearer tvly-test".to_string())] + ); + task.abort(); + } + + #[tokio::test] + async fn rate_limits_and_redirects_are_neither_retried_nor_followed() { + let cases = [ + (429, "", "Tavily returned HTTP 429."), + (302, "location: /elsewhere\r\n", "Tavily returned HTTP 302."), + ]; + for (status, headers, expected) in cases { + let (url, recorded, task) = serve(vec![(status, headers, "{}"), (200, "", "{}")]).await; + let provider = TavilyProvider::with_usage_url(&url); + match provider.fetch_usage_impl(&keyed_context()).await { + Err(ProviderError::Other(message)) => assert_eq!(message, expected), + other => panic!("status {status}: unexpected {other:?}"), + } + assert_eq!(recorded.lock().unwrap().len(), 1, "status {status}"); + task.abort(); + } + } } From d9a3e393aa44ce32877477fece385de98b1a261c Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Tue, 13 Oct 2026 11:55:13 +0700 Subject: [PATCH 11/11] Test wrong-typed Tavily pay-as-you-go fields --- rust/src/providers/tavily/mod.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rust/src/providers/tavily/mod.rs b/rust/src/providers/tavily/mod.rs index f4099176b5..05c343b47c 100644 --- a/rust/src/providers/tavily/mod.rs +++ b/rust/src/providers/tavily/mod.rs @@ -447,6 +447,14 @@ mod tests { json!({ "plan_usage": 1, "plan_limit": 2, "paygo_usage": 1 }), ok_key.clone(), ))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": 1, "plan_limit": 2, "paygo_usage": "1", "paygo_limit": 5 }), + ok_key.clone(), + ))); + assert_unrecognized(parse_usage(&payload( + json!({ "plan_usage": 1, "plan_limit": 2, "paygo_usage": 1, "paygo_limit": null }), + ok_key.clone(), + ))); } #[test]