diff --git a/README.md b/README.md index f1d09b41da2..49c79cc1532 100755 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Ollama | Cookies / API Key | Usage, Cloud Models, Pace windows | | Azure OpenAI | API Key | Deployment | | T3 Chat | Cookies / cURL | Base, Overage | +| TinyApi | Browser cookies / manual Cookie header | Available credits | | TypeSafe | Browser cookies / manual Cookie header | Billing-cycle spend, balance, expiring credits | | OpenRouter | API Key | Credits | | JetBrains AI | Local Config | Usage | diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs index 40314f71829..e326b058360 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -255,6 +255,7 @@ fn cookie_source_provider(provider_id: &str) -> Option ProviderId::MuseAI, "lithosai" => ProviderId::LithosAI, "langdock" => ProviderId::Langdock, + "tinyapi" => ProviderId::TinyApi, _ => return None, }) } @@ -892,6 +893,13 @@ pub fn cookie_source_options_for(provider_id: &str, lang: Language) -> Vec vec![ + cookie_option(lang, "auto", None), + cookie_option(lang, "manual", None), + cookie_option(lang, "off", None), + ], _ => Vec::new(), } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs index d86d2dae873..7b3507650c1 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/session_cookie_scope_tests.rs @@ -324,3 +324,44 @@ fn workbuddy_exposes_a_cookie_source_picker_and_routes_each_choice() { assert_eq!(ctx.source_mode, SourceMode::Cli); assert!(ctx.manual_cookie_header.is_none()); } + +/// TinyApi is web-only: the picker chooses the session, a stored header +/// always wins, an empty manual source fails closed without reading a browser, +/// and off reaches the provider as Cli (its "cookies are disabled" error). +#[test] +fn tinyapi_exposes_a_cookie_source_picker_and_routes_each_choice() { + let settings = Settings::default(); + assert_eq!( + super::provider_cookie_source_lookup(&settings, "tinyapi").as_deref(), + Some("auto") + ); + let values: Vec<_> = super::cookie_source_options_for("tinyapi", Language::English) + .into_iter() + .map(|option| option.value) + .collect(); + assert_eq!(values, vec!["auto", "manual", "off"]); + + let cookie = "session=fixture"; + for source in ["auto", "manual"] { + let ctx = session_context(ProviderId::TinyApi, Some(source), "auto", Some(cookie)); + assert!( + matches!(ctx.source_mode, SourceMode::Auto | SourceMode::Web), + "{source}" + ); + assert_eq!( + ctx.manual_cookie_header.as_deref(), + Some(cookie), + "{source}" + ); + assert!(!ctx.manual_cookie_missing, "{source}"); + } + + let ctx = session_context(ProviderId::TinyApi, Some("manual"), "auto", None); + assert_eq!(ctx.source_mode, SourceMode::Web); + assert!(ctx.manual_cookie_header.is_none()); + assert!(ctx.manual_cookie_missing); + + let ctx = session_context(ProviderId::TinyApi, Some("off"), "auto", Some(cookie)); + assert_eq!(ctx.source_mode, SourceMode::Cli); + assert!(ctx.manual_cookie_header.is_none()); +} diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tinyapi.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tinyapi.svg new file mode 100644 index 00000000000..a7e7ad16523 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-tinyapi.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts index f45ff58f06f..a66c89255f1 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.test.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.test.ts @@ -85,6 +85,7 @@ const MAC_PARITY_ACCENTS: Array<[string, string]> = [ ["sakana", "#2975db"], ["stepfun", "#2196f2"], ["t3chat", "#f56647"], + ["tinyapi", "#f97316"], ["typesafe", "#111111"], ["v0", "#111111"], ["warp", "#938bb4"], diff --git a/apps/desktop-tauri/src/components/providers/providerIcons.ts b/apps/desktop-tauri/src/components/providers/providerIcons.ts index 97d78732860..285a4620e61 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -75,6 +75,7 @@ import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; import synthetic from "./icons/ProviderIcon-synthetic.svg?raw"; import clawrouter from "./icons/ProviderIcon-clawrouter.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.svg?raw"; +import tinyapi from "./icons/ProviderIcon-tinyapi.svg?raw"; import venice from "./icons/ProviderIcon-venice.svg?raw"; import vercel from "./icons/ProviderIcon-vercel.svg?raw"; import vertexai from "./icons/ProviderIcon-vertexai.svg?raw"; @@ -179,6 +180,7 @@ const RAW: Record = { synthetic: tint(synthetic), clawrouter: tint(clawrouter), t3chat: tint(t3chat), + tinyapi: tint(tinyapi), venice: tint(venice), vercel: tint(vercel), vertexai: tint(vertexai), @@ -243,6 +245,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { ollama: { id: "ollama", brandColor: "#888888", fallbackLetter: "○", svgPath: RAW.ollama }, azureopenai: { id: "azureopenai", brandColor: "#0078d4", fallbackLetter: "A" }, t3chat: { id: "t3chat", brandColor: "#f56647", fallbackLetter: "T", svgPath: RAW.t3chat }, + tinyapi: { id: "tinyapi", brandColor: "#f97316", fallbackLetter: "T", svgPath: RAW.tinyapi }, opencode: { id: "opencode", brandColor: "#3b82f6", fallbackLetter: "○", svgPath: RAW.opencode }, opencodego: { id: "opencodego", brandColor: "#3b82f6", fallbackLetter: "○", svgPath: RAW.opencodego }, openrouter: { id: "openrouter", brandColor: "#6467f2", fallbackLetter: "R", svgPath: RAW.openrouter }, diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index 787176a9289..70f35cebb0b 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -60,6 +60,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["huggingface", "Hugging Face"], ["helmcode", "Helmcode"], ["v0", "v0"], + ["tinyapi", "TinyApi"], ["typesafe", "TypeSafe"], ["llmproxy", "LLM Proxy"], ["chutes", "Chutes"], diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 24616edc750..2c5e8237c50 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -182,6 +182,36 @@ to `https://muse.ai/` with the session cookies and `Sec-Fetch-*` headers. - A 401, a 403 or a redirect to `auth.muse.ai` is reported as an expired session. Response bodies and cookies are never logged or echoed in errors. +### TinyApi + +TinyApi reads available credit balance from the signed-in dashboard using +`GET https://tinyapi.rest/api/user/credits` with session cookies or a manual +Cookie header. The response returns a JSON object with a `data.totalAvailable` +field containing the available credits as a number. + +- The cookie source in Settings → Providers → TinyApi picks the session and + defaults to Automatic. Automatic imports the `tinyapi.rest` cookies from the + browser you select (Chrome, Edge, Brave, Firefox); Manual accepts a pasted + Cookie header. Without an imported or pasted header, Automatic tries each + detected browser's `tinyapi.rest` cookies in turn, and Manual fails closed. + Off reads nothing. +- Requests send the Cookie header and expect a JSON response with `success: true` + and `data.totalAvailable` as a finite non-negative number. HTTP 401 or 403 + indicates an expired session; the provider skips to the next browser session + if Automatic has more candidates, and reports "TinyApi session expired. Sign + in again or paste a fresh Cookie header." if none remain. HTTP 429 is + rate-limited, and 500+ is unavailable. Other HTTP codes are reported with + their status code. Bodies over 256 KiB or without a valid balance report a + missing balance, and non-JSON bodies report invalid JSON. Response bodies and + cookies are never logged or echoed in errors. +- The card shows a single "Available credits" detail row with the balance + formatted to up to two decimal places and the "credits" unit (e.g. "125.5 + credits" or "100 credits"). The menu bar reads "Available credits: " + from an informational window that is hidden on the card. Login method is + shown as "Browser session". +- No multi-account support. A transport or timeout error while reading the + body keeps its cause instead of being reported as an invalid response. + ### WorkBuddy credits WorkBuddy is Tencent's desktop AI work assistant, billed in credits. It is diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index ce9db7e0c69..66609d2d9ff 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -66,6 +66,7 @@ pub enum ProviderId { HuggingFace, Helmcode, V0, + TinyApi, TypeSafe, LLMProxy, Chutes, @@ -172,6 +173,7 @@ impl ProviderId { ProviderId::HuggingFace, ProviderId::Helmcode, ProviderId::V0, + ProviderId::TinyApi, ProviderId::TypeSafe, ProviderId::LLMProxy, ProviderId::Chutes, @@ -299,6 +301,7 @@ impl ProviderId { ProviderId::HuggingFace => "huggingface", ProviderId::Helmcode => "helmcode", ProviderId::V0 => "v0", + ProviderId::TinyApi => "tinyapi", ProviderId::TypeSafe => "typesafe", ProviderId::LLMProxy => "llmproxy", ProviderId::Chutes => "chutes", @@ -405,6 +408,7 @@ impl ProviderId { ProviderId::HuggingFace => "Hugging Face", ProviderId::Helmcode => "Helmcode", ProviderId::V0 => "v0", + ProviderId::TinyApi => "TinyApi", ProviderId::TypeSafe => "TypeSafe", ProviderId::LLMProxy => "LLM Proxy", ProviderId::Chutes => "Chutes", @@ -524,6 +528,7 @@ impl ProviderId { ProviderId::Groq => Some("groq.com"), ProviderId::HuggingFace => None, ProviderId::Helmcode => Some("helmcode.com"), + ProviderId::TinyApi => Some("tinyapi.rest"), ProviderId::TypeSafe => Some("typesafe.ai"), ProviderId::V0 => None, ProviderId::LLMProxy => None, @@ -642,6 +647,7 @@ impl ProviderId { "huggingface" | "hugging-face" | "hugging face" | "hf" => Some(ProviderId::HuggingFace), "helmcode" | "nan-builders" | "nan builders" => Some(ProviderId::Helmcode), "v0" | "v0-dev" | "v0.dev" => Some(ProviderId::V0), + "tinyapi" | "tiny-api" => Some(ProviderId::TinyApi), "typesafe" | "type-safe" | "type safe" => Some(ProviderId::TypeSafe), "llmproxy" | "llm-proxy" | "llm proxy" => Some(ProviderId::LLMProxy), "chutes" | "chutes-ai" | "chutes ai" => Some(ProviderId::Chutes), @@ -1353,6 +1359,7 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::HuggingFace => "#FFD21E", ProviderId::Helmcode => "#4934E1", ProviderId::V0 => "#111111", + ProviderId::TinyApi => "#F97316", ProviderId::TypeSafe => "#111111", ProviderId::LLMProxy => "#24B47E", ProviderId::Chutes => "#3184FF", @@ -1416,7 +1423,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 99); + assert_eq!(all.len(), 100); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1496,6 +1503,7 @@ mod tests { assert!(all.contains(&ProviderId::MuseAI)); assert!(all.contains(&ProviderId::WorkBuddy)); assert!(all.contains(&ProviderId::SailResearch)); + assert!(all.contains(&ProviderId::TinyApi)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index d919624dcde..f631f927673 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -30,10 +30,10 @@ use crate::providers::{ NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PiProvider, PoeProvider, QoderProvider, QwenCloudProvider, RaycastProvider, ReplicateProvider, SailResearchProvider, SakanaProvider, - SofyaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, TypeSafeProvider, V0Provider, - VeniceProvider, VercelProvider, VertexAIProvider, WarpProvider, WayfinderProvider, - WindsurfProvider, XKiroProvider, XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, - ZoomMateProvider, + SofyaProvider, StepFunProvider, Sub2ApiProvider, T3ChatProvider, TinyApiProvider, + TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, VertexAIProvider, WarpProvider, + WayfinderProvider, WindsurfProvider, XKiroProvider, XaiProvider, ZaiProvider, ZedProvider, + ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -104,6 +104,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::HuggingFace => Box::new(HuggingFaceProvider::new()), ProviderId::Helmcode => Box::new(HelmcodeProvider::new()), ProviderId::V0 => Box::new(V0Provider::new()), + ProviderId::TinyApi => Box::new(TinyApiProvider::new()), ProviderId::TypeSafe => Box::new(TypeSafeProvider::new()), ProviderId::LLMProxy => Box::new(LLMProxyProvider::new()), ProviderId::Chutes => Box::new(ChutesProvider::new()), @@ -214,6 +215,7 @@ pub fn pace_capability(id: ProviderId) -> ProviderPaceCapability { | ProviderId::HuggingFace | ProviderId::Helmcode | ProviderId::V0 + | ProviderId::TinyApi | ProviderId::TypeSafe | ProviderId::LLMProxy | ProviderId::Chutes diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 9b6d0989f70..57622074ba6 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -451,7 +451,8 @@ impl TokenAccountSupport { | ProviderId::MuseAI | ProviderId::WorkBuddy | ProviderId::SailResearch - | ProviderId::Sofya => None, + | ProviderId::Sofya + | ProviderId::TinyApi => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index 704291f43b9..61b08f5d50f 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -94,6 +94,7 @@ pub mod stepfun; pub mod sub2api; pub mod synthetic; pub mod t3chat; +pub mod tinyapi; pub mod typesafe; pub mod v0; pub mod venice; @@ -195,6 +196,7 @@ pub use stepfun::StepFunProvider; pub use sub2api::Sub2ApiProvider; pub use synthetic::SyntheticProvider; pub use t3chat::T3ChatProvider; +pub use tinyapi::TinyApiProvider; pub use typesafe::TypeSafeProvider; pub use v0::V0Provider; pub use venice::VeniceProvider; diff --git a/rust/src/providers/tinyapi/mod.rs b/rust/src/providers/tinyapi/mod.rs new file mode 100644 index 00000000000..75dc325a7d5 --- /dev/null +++ b/rust/src/providers/tinyapi/mod.rs @@ -0,0 +1,291 @@ +//! TinyApi available credit balance provider. +//! +//! Fetches account available credit balance from `GET https://tinyapi.rest/api/user/credits` +//! using signed-in browser session cookies or manual Cookie headers. Tries each browser's +//! cookies in order; skips rejected (401/403) sessions and moves to the next candidate. +//! A pasted or manual Cookie header is the only session tried. Without one, Automatic tries +//! every detected browser holding cookies for tinyapi.rest, in browser order. +//! +//! Ported from upstream CodexBar v0.74.1 (`Resources/Plugins/tinyapi.js`). + +#[cfg(test)] +mod tests; + +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::{Client, StatusCode}; +use serde_json::Value; + +use super::format; +use crate::core::{ + FetchContext, ManualEmptyCookiePolicy, Provider, ProviderDisplayDetail, ProviderError, + ProviderFetchResult, ProviderId, ProviderMetadata, ProviderStateKind, RateWindow, SourceMode, + UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const USAGE_URL: &str = "https://tinyapi.rest/api/user/credits"; +const COOKIE_DOMAIN: &str = "tinyapi.rest"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_RESPONSE_BYTES: usize = 256 * 1024; + +const COOKIES_OFF: &str = "TinyApi cookies are disabled."; +const SESSION_EXPIRED: &str = + "TinyApi session expired. Sign in again or paste a fresh Cookie header."; +const MISSING_SESSION: &str = + "Sign in to tinyapi.rest in the selected browser. Or set a manual Cookie header."; + +/// Every detected browser's Cookie header for tinyapi.rest, in browser order. +type BrowserCookieHeaders = dyn Fn() -> Result, ProviderError> + Sync; + +pub struct TinyApiProvider { + client: Option, + usage_url: String, +} + +impl TinyApiProvider { + pub fn new() -> Self { + Self::with_usage_url(USAGE_URL) + } + + fn with_usage_url(usage_url: &str) -> Self { + let client = crate::core::credentialed_http_client_builder() + .cookie_store(false) + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .ok(); + Self { + client, + usage_url: usage_url.to_owned(), + } + } + + /// Tries each candidate session in order. Only an expired session (401/403) + /// moves on to the next candidate; any other failure ends the fetch. + async fn fetch_web( + &self, + ctx: &FetchContext, + browser: &BrowserCookieHeaders, + ) -> Result { + let (sessions, browser_error) = sessions(ctx, browser); + if sessions.is_empty() { + return Err(browser_error.unwrap_or_else(missing_session)); + } + let client = self.client.as_ref().ok_or_else(|| { + ProviderError::Other("Could not create a secure TinyApi HTTP client.".into()) + })?; + let timeout = Duration::from_secs(ctx.web_timeout.max(1)).min(REQUEST_TIMEOUT); + let mut last_error = None; + for cookie_header in sessions { + match fetch_credits(client, &self.usage_url, &cookie_header, timeout).await { + Err(error) if is_session_expired(&error) => last_error = Some(error), + result => return result, + } + } + Err(last_error.unwrap_or_else(missing_session)) + } +} + +impl Default for TinyApiProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for TinyApiProvider { + fn id(&self) -> ProviderId { + ProviderId::TinyApi + } + + fn metadata(&self) -> &ProviderMetadata { + &TINYAPI_METADATA + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::Web => { + self.fetch_web(ctx, &|| { + crate::providers::browser_cookie_headers_for_domain(COOKIE_DOMAIN) + }) + .await + } + // The shell maps an Off cookie source to Cli for web-only + // providers; upstream reports that cookies are disabled. + SourceMode::Cli => Err(ProviderError::Other(COOKIES_OFF.into())), + SourceMode::OAuth => Err(ProviderError::UnsupportedSource(SourceMode::OAuth)), + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::Web] + } + + fn supports_web(&self) -> bool { + true + } + + /// Upstream sends only the pasted header in Manual mode, so an empty + /// manual source must not fall back to a browser session. + fn manual_empty_cookie_policy(&self) -> ManualEmptyCookiePolicy { + ManualEmptyCookiePolicy::FailClosedWeb + } + + /// The shell would import only the first browser's cookies; the provider + /// reads every browser so an expired session can move to the next one. + fn owns_browser_cookie_resolution(&self) -> bool { + true + } + + fn error_state_kind(&self, error: &ProviderError) -> ProviderStateKind { + if is_session_expired(error) { + ProviderStateKind::ExpiredSession + } else { + error.state_kind() + } + } +} + +/// Fetch credits from a single cookie header. +async fn fetch_credits( + client: &Client, + usage_url: &str, + cookie_header: &str, + timeout: Duration, +) -> Result { + let response = client + .get(usage_url) + .header(reqwest::header::COOKIE, cookie_header) + .timeout(timeout) + .send() + .await?; + + let status = response.status(); + check_status(status)?; + + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::TooLarge => invalid_response(), + BoundedBodyError::Read(error) => ProviderError::from(error), + })?; + + let root: Value = serde_json::from_slice(&body) + .map_err(|_| ProviderError::Other("TinyApi credits response is not valid JSON.".into()))?; + + parse_usage(&root) +} + +fn is_session_expired(error: &ProviderError) -> bool { + matches!(error, ProviderError::Other(message) if message == SESSION_EXPIRED) +} + +fn missing_session() -> ProviderError { + ProviderError::BrowserSignInRequired { + message: MISSING_SESSION.into(), + sign_in_url: "https://tinyapi.rest".into(), + } +} + +/// Candidate sessions in upstream's order: a pasted or shell-resolved Cookie +/// header is used alone, an empty Manual source offers nothing, and otherwise +/// every browser holding cookies for tinyapi.rest is a candidate. +fn sessions( + ctx: &FetchContext, + browser: &BrowserCookieHeaders, +) -> (Vec, Option) { + if let Some(header) = ctx.manual_cookie_header.as_deref() { + let header = crate::providers::normalize_cookie_header(header); + return (header.into_iter().collect(), None); + } + if ctx.manual_cookie_missing { + return (Vec::new(), None); + } + match browser() { + Ok(headers) => ( + headers + .iter() + .filter_map(|(_, header)| crate::providers::normalize_cookie_header(header)) + .collect(), + None, + ), + // No browser holds tinyapi cookies: the sign-in hint is clearer. + Err(ProviderError::NoCookies) => (Vec::new(), None), + Err(error) => { + tracing::debug!(%error, "TinyApi browser sessions are unavailable"); + (Vec::new(), Some(error)) + } + } +} + +/// HTTP status taxonomy; only 200 continues to the body. +fn check_status(status: StatusCode) -> Result<(), ProviderError> { + let message = match status.as_u16() { + 200 => return Ok(()), + 401 | 403 => SESSION_EXPIRED.to_owned(), + 429 => "TinyApi credits requests are rate limited.".to_owned(), + code @ 500.. => format!("TinyApi credits service returned HTTP {code}."), + code => format!("TinyApi credits request returned HTTP {code}."), + }; + Err(ProviderError::Other(message)) +} + +fn invalid_response() -> ProviderError { + ProviderError::Other("TinyApi credits response is missing a valid available balance.".into()) +} + +pub fn parse_usage(root: &Value) -> Result { + let root = root.as_object().ok_or_else(invalid_response)?; + + // Check success field + if root.get("success") != Some(&Value::Bool(true)) { + return Err(invalid_response()); + } + + // Extract available balance + let data = root + .get("data") + .and_then(Value::as_object) + .ok_or_else(invalid_response)?; + + let balance = data + .get("totalAvailable") + .and_then(|v| v.as_f64()) + .ok_or_else(invalid_response)?; + + // Validate: must be finite and non-negative + if !balance.is_finite() || balance < 0.0 { + return Err(invalid_response()); + } + + // Format balance with up to 2 decimal places + let balance_text = format::number(balance, 2); + let value_text = format!("{} credits", balance_text); + + let primary = + RateWindow::informational(format!("Available credits: {value_text}")).hidden_on_card(); + let usage = UsageSnapshot::new(primary).with_login_method("Browser session"); + + let mut result = ProviderFetchResult::new(usage, "web"); + result = result.with_display_detail( + ProviderDisplayDetail::new("tinyapi-available", "Available credits", value_text) + .and_then(|d| d.with_section_title("Credits")), + ); + Ok(result) +} + +const TINYAPI_METADATA: ProviderMetadata = ProviderMetadata { + id: ProviderId::TinyApi, + display_name: "TinyApi", + session_label: "Credits", + weekly_label: "Credits", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://tinyapi.rest/dashboard"), + status_page_url: None, + tertiary_label_key: None, +}; diff --git a/rust/src/providers/tinyapi/tests.rs b/rust/src/providers/tinyapi/tests.rs new file mode 100644 index 00000000000..bd701d88c92 --- /dev/null +++ b/rust/src/providers/tinyapi/tests.rs @@ -0,0 +1,421 @@ +#[cfg(test)] +mod tinyapi_tests { + use crate::core::{Provider, ProviderId}; + use crate::providers::tinyapi::parse_usage; + use serde_json::json; + + fn detail_values(result: &crate::core::ProviderFetchResult) -> Vec<(String, String)> { + result + .display_details + .iter() + .map(|d| (d.title().to_string(), d.value().to_string())) + .collect() + } + + #[test] + fn parse_usage_accepts_valid_balance() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": 125.5 } + })) + .unwrap(); + + assert_eq!( + detail_values(&result), + vec![("Available credits".to_string(), "125.5 credits".to_string())] + ); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Available credits: 125.5 credits") + ); + } + + #[test] + fn parse_usage_formats_with_up_to_two_decimals() { + let test_cases = vec![ + (0.0, "0 credits"), + (1.0, "1 credits"), + (1.1, "1.1 credits"), + (1.12, "1.12 credits"), + (1.123, "1.12 credits"), + (100.0, "100 credits"), + (100.5, "100.5 credits"), + (100.55, "100.55 credits"), + ]; + + for (balance, expected) in test_cases { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": balance } + })) + .unwrap(); + + assert_eq!( + detail_values(&result), + vec![("Available credits".to_string(), expected.to_string())], + "balance {balance} should format as {expected}" + ); + } + } + + #[test] + fn parse_usage_rejects_missing_success_field() { + let result = parse_usage(&json!({ + "data": { "totalAvailable": 125.5 } + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_success_false() { + let result = parse_usage(&json!({ + "success": false, + "data": { "totalAvailable": 125.5 } + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_missing_data_object() { + let result = parse_usage(&json!({ + "success": true + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_missing_total_available() { + let result = parse_usage(&json!({ + "success": true, + "data": {} + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_non_numeric_balance() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": "125" } + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_null_balance() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": null } + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_negative_balance() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": -1.5 } + })); + assert!(result.is_err()); + } + + #[test] + fn parse_usage_rejects_non_finite_balance() { + // serde_json encodes non-finite floats as null, so this is the shape a + // non-finite balance takes on the wire. + for value in [f64::INFINITY, f64::NEG_INFINITY, f64::NAN] { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": value } + })); + assert!(result.is_err(), "{value} should be rejected"); + } + } + + #[test] + fn parse_usage_result_has_hidden_on_card() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": 125.5 } + })) + .unwrap(); + + assert!(result.usage.primary.hidden_on_card); + } + + #[test] + fn parse_usage_result_has_login_method() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": 125.5 } + })) + .unwrap(); + + assert_eq!( + result.usage.login_method.as_deref(), + Some("Browser session") + ); + } + + #[test] + fn parse_usage_result_uses_web_source_label() { + let result = parse_usage(&json!({ + "success": true, + "data": { "totalAvailable": 125.5 } + })) + .unwrap(); + + assert_eq!(result.source_label, "web"); + } + + #[test] + fn provider_metadata() { + use crate::providers::tinyapi::TinyApiProvider; + let provider = TinyApiProvider::new(); + assert_eq!(provider.id(), ProviderId::TinyApi); + assert_eq!(provider.metadata().display_name, "TinyApi"); + assert_eq!(provider.metadata().session_label, "Credits"); + assert!(!provider.metadata().supports_opus); + assert!(!provider.metadata().supports_credits); + assert!(!provider.metadata().default_enabled); + assert!(!provider.metadata().is_primary); + } + + #[test] + fn provider_available_sources() { + use crate::providers::tinyapi::TinyApiProvider; + let provider = TinyApiProvider::new(); + let sources = provider.available_sources(); + assert_eq!(sources.len(), 2); + assert!(sources.contains(&crate::core::SourceMode::Auto)); + assert!(sources.contains(&crate::core::SourceMode::Web)); + } + + #[test] + fn provider_supports_web() { + use crate::providers::tinyapi::TinyApiProvider; + let provider = TinyApiProvider::new(); + assert!(provider.supports_web()); + } + + #[test] + fn provider_manual_empty_cookie_policy() { + use crate::core::ManualEmptyCookiePolicy; + use crate::providers::tinyapi::TinyApiProvider; + let provider = TinyApiProvider::new(); + assert_eq!( + provider.manual_empty_cookie_policy(), + ManualEmptyCookiePolicy::FailClosedWeb + ); + } + + #[test] + fn provider_owns_browser_cookie_resolution() { + use crate::providers::tinyapi::TinyApiProvider; + let provider = TinyApiProvider::new(); + assert!(provider.owns_browser_cookie_resolution()); + } + + #[test] + fn status_errors_match_upstream() { + use crate::providers::tinyapi::{SESSION_EXPIRED, check_status}; + use reqwest::StatusCode; + + let message = |code: u16| match check_status(StatusCode::from_u16(code).unwrap()) { + Err(crate::core::ProviderError::Other(message)) => message, + other => panic!("unexpected {other:?}"), + }; + assert!(check_status(StatusCode::OK).is_ok()); + assert_eq!(message(401), SESSION_EXPIRED); + assert_eq!(message(403), SESSION_EXPIRED); + assert_eq!(message(429), "TinyApi credits requests are rate limited."); + assert_eq!(message(503), "TinyApi credits service returned HTTP 503."); + assert_eq!(message(404), "TinyApi credits request returned HTTP 404."); + } + + #[test] + fn manual_header_is_the_only_candidate() { + use crate::core::FetchContext; + use crate::providers::tinyapi::sessions; + + let ctx = FetchContext { + manual_cookie_header: Some("session=manual".into()), + ..FetchContext::default() + }; + let browser = || -> Result, crate::core::ProviderError> { + panic!("browser cookies must not be read when a manual header is set") + }; + let (candidates, error) = sessions(&ctx, &browser); + assert_eq!(candidates, vec!["session=manual".to_string()]); + assert!(error.is_none()); + } + + #[test] + fn empty_manual_source_offers_no_candidates() { + use crate::core::FetchContext; + use crate::providers::tinyapi::sessions; + + let ctx = FetchContext { + manual_cookie_missing: true, + ..FetchContext::default() + }; + let browser = || -> Result, crate::core::ProviderError> { + panic!("an empty manual source must not fall back to browsers") + }; + assert!(sessions(&ctx, &browser).0.is_empty()); + } + + #[test] + fn every_browser_session_is_a_candidate_in_order() { + use crate::core::FetchContext; + use crate::providers::tinyapi::sessions; + + let browser = || { + Ok(vec![ + ("Chrome".to_string(), "a=1".to_string()), + ("Edge".to_string(), "b=2".to_string()), + ]) + }; + let (candidates, _) = sessions(&FetchContext::default(), &browser); + assert_eq!(candidates, vec!["a=1".to_string(), "b=2".to_string()]); + + let none = || Err(crate::core::ProviderError::NoCookies); + let (candidates, error) = sessions(&FetchContext::default(), &none); + assert!(candidates.is_empty()); + assert!(error.is_none()); + } + + #[tokio::test] + async fn off_and_missing_sessions_report_upstream_messages() { + use crate::core::{FetchContext, ProviderError, SourceMode}; + use crate::providers::tinyapi::{MISSING_SESSION, TinyApiProvider}; + + let provider = TinyApiProvider::new(); + let off = FetchContext { + source_mode: SourceMode::Cli, + ..FetchContext::default() + }; + match provider.fetch_usage(&off).await { + Err(ProviderError::Other(message)) => { + assert_eq!(message, "TinyApi cookies are disabled.") + } + other => panic!("unexpected {other:?}"), + } + + let none = || Err(ProviderError::NoCookies); + match provider.fetch_web(&FetchContext::default(), &none).await { + Err(ProviderError::BrowserSignInRequired { message, .. }) => { + assert_eq!(message, MISSING_SESSION) + } + other => panic!("unexpected {other:?}"), + } + } + + /// Serves one canned response per connection, recording each Cookie header. + async fn serve_in_order( + responses: Vec<(u16, &'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:{}/api/user/credits", + listener.local_addr().unwrap().port() + ); + let cookies = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = std::sync::Arc::clone(&cookies); + let task = tokio::spawn(async move { + for (status, 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 cookie = text + .lines() + .find_map(|line| { + line.strip_prefix("cookie: ") + .or_else(|| line.strip_prefix("Cookie: ")) + }) + .unwrap_or_default() + .to_string(); + recorded.lock().unwrap().push(cookie); + let response = format!( + "HTTP/1.1 {status} X\r\ncontent-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, cookies, task) + } + + #[tokio::test] + async fn an_expired_browser_session_moves_to_the_next_one() { + use crate::core::FetchContext; + use crate::providers::tinyapi::TinyApiProvider; + + let (url, cookies, task) = serve_in_order(vec![ + (401, "{}"), + (200, r#"{"success":true,"data":{"totalAvailable":7}}"#), + ]) + .await; + let provider = TinyApiProvider::with_usage_url(&url); + let browser = || { + Ok(vec![ + ("Chrome".to_string(), "a=1".to_string()), + ("Edge".to_string(), "b=2".to_string()), + ]) + }; + let result = provider + .fetch_web(&FetchContext::default(), &browser) + .await + .unwrap(); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("Available credits: 7 credits") + ); + assert_eq!(*cookies.lock().unwrap(), vec!["a=1", "b=2"]); + task.abort(); + } + + #[tokio::test] + async fn a_non_auth_failure_stops_at_the_first_session() { + use crate::core::{FetchContext, ProviderError}; + use crate::providers::tinyapi::TinyApiProvider; + + let (url, cookies, task) = serve_in_order(vec![(429, "{}"), (200, "{}")]).await; + let provider = TinyApiProvider::with_usage_url(&url); + let browser = || { + Ok(vec![ + ("Chrome".to_string(), "a=1".to_string()), + ("Edge".to_string(), "b=2".to_string()), + ]) + }; + match provider.fetch_web(&FetchContext::default(), &browser).await { + Err(ProviderError::Other(message)) => { + assert_eq!(message, "TinyApi credits requests are rate limited.") + } + other => panic!("unexpected {other:?}"), + } + assert_eq!(*cookies.lock().unwrap(), vec!["a=1"]); + task.abort(); + } +} diff --git a/rust/src/settings.rs b/rust/src/settings.rs index 109513e6e7a..56b0a6ff88c 100755 --- a/rust/src/settings.rs +++ b/rust/src/settings.rs @@ -1209,7 +1209,8 @@ impl Settings { | ProviderId::Langdock | ProviderId::LithosAI | ProviderId::MuseAI - | ProviderId::WorkBuddy => "auto", + | ProviderId::WorkBuddy + | ProviderId::TinyApi => "auto", _ => DEFAULT_COOKIE_SOURCE, }) } diff --git a/rust/src/settings/tests.rs b/rust/src/settings/tests.rs index 46191005eac..8ec6df5c58b 100644 --- a/rust/src/settings/tests.rs +++ b/rust/src/settings/tests.rs @@ -97,6 +97,15 @@ fn workbuddy_cookie_source_defaults_to_automatic_session_import() { assert_eq!(settings.cookie_source(ProviderId::WorkBuddy), "off"); } +#[test] +fn tinyapi_cookie_source_defaults_to_automatic_session_import() { + // Upstream's TinyApi cookie source falls back to `.auto`. + let mut settings = Settings::default(); + assert_eq!(settings.cookie_source(ProviderId::TinyApi), "auto"); + settings.set_cookie_source(ProviderId::TinyApi, "off"); + assert_eq!(settings.cookie_source(ProviderId::TinyApi), "off"); +} + #[test] fn preferred_currency_defaults_validates_and_round_trips() { let legacy: Settings = serde_json::from_str(r#"{"enabled_providers": []}"#)