From 47f55c7a726364897075f7f4ba59c2feff2014fe Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:28:55 +0700 Subject: [PATCH] Port upstream 0.66.0: Vercel AI Gateway credit balance provider --- README.md | 1 + .../providers/icons/ProviderIcon-vercel.svg | 3 + .../src/components/providers/providerIcons.ts | 3 + apps/desktop-tauri/src/surfaces/TrayPanel.tsx | 2 +- .../surfaces/settings/tabs/ProvidersTab.tsx | 1 + .../desktop-tauri/src/test/providerCatalog.ts | 1 + rust/src/core/provider.rs | 17 +- rust/src/core/provider_factory.rs | 7 +- rust/src/core/token_accounts.rs | 3 +- rust/src/providers/mod.rs | 2 + rust/src/providers/vercel/mod.rs | 221 +++++++++++++ rust/src/providers/vercel/tests.rs | 295 ++++++++++++++++++ rust/src/settings/api_keys.rs | 11 + 13 files changed, 561 insertions(+), 6 deletions(-) create mode 100644 apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg create mode 100644 rust/src/providers/vercel/mod.rs create mode 100644 rust/src/providers/vercel/tests.rs diff --git a/README.md b/README.md index 2e052ad2f9..4269d1a9f5 100755 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ See the full history in [CHANGELOG.md](CHANGELOG.md). | Kiro | Cookies / CLI | Monthly Credits, Overage | | Vertex AI | gcloud OAuth | Cost | | v0 | API Key | Billing quota, API rate limits, on-demand balance | +| Vercel AI Gateway | API Key | Team credit balance, lifetime spend | | Augment | Cookies | Credits | | OpenCode | Local Config | Usage | | Kimi | Cookies | 5h Rate, Weekly | diff --git a/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.svg new file mode 100644 index 0000000000..b8ff338636 --- /dev/null +++ b/apps/desktop-tauri/src/components/providers/icons/ProviderIcon-vercel.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 2fdc4380d7..a46cab5434 100644 --- a/apps/desktop-tauri/src/components/providers/providerIcons.ts +++ b/apps/desktop-tauri/src/components/providers/providerIcons.ts @@ -60,6 +60,7 @@ import stepfun from "./icons/ProviderIcon-stepfun.svg?raw"; import sub2api from "./icons/ProviderIcon-sub2api.svg?raw"; import t3chat from "./icons/ProviderIcon-t3chat.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"; import warp from "./icons/ProviderIcon-warp.svg?raw"; import windsurf from "./icons/ProviderIcon-windsurf.svg?raw"; @@ -148,6 +149,7 @@ const RAW: Record = { sub2api: tint(sub2api), t3chat: tint(t3chat), venice: tint(venice), + vercel: tint(vercel), vertexai: tint(vertexai), warp: tint(warp), windsurf: tint(windsurf), @@ -230,6 +232,7 @@ export const PROVIDER_ICON_REGISTRY: Record = { stepfun: { id: "stepfun", brandColor: "#999999", fallbackLetter: "S", svgPath: RAW.stepfun }, sub2api: { id: "sub2api", brandColor: "#2dc6d8", fallbackLetter: "S", svgPath: RAW.sub2api }, venice: { id: "venice", brandColor: "#111827", fallbackLetter: "V", svgPath: RAW.venice }, + vercel: { id: "vercel", brandColor: "#737373", fallbackLetter: "V", svgPath: RAW.vercel }, openaiapi: { id: "openaiapi", brandColor: "#10a37f", fallbackLetter: "O" }, chutes: { id: "chutes", brandColor: "#ff5c35", fallbackLetter: "C" }, litellm: { id: "litellm", brandColor: "#0ea5e9", fallbackLetter: "L" }, diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx index ccc2e8fe2f..b50eb5e823 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.tsx @@ -36,7 +36,7 @@ const HAS_DASHBOARD = new Set([ "mimo", "minimax", "mistral", "nanogpt", "notion", "ollama", "openaiapi", "opencode", "opencodego", "openrouter", "perplexity", "qoder", "codebuddy", "sakana", "stepfun", "t3chat", "venice", "vertexai", "warp", "windsurf", - "xai", "zai", "fireworks", "meta", "muse", "nous", + "xai", "zai", "fireworks", "meta", "muse", "nous", "vercel", ]); /** Provider IDs that have a status page URL in the backend */ const HAS_STATUS_PAGE = new Set([ diff --git a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx index 48c200779c..1713c8336f 100644 --- a/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/tabs/ProvidersTab.tsx @@ -253,6 +253,7 @@ function providerSourceHintShort( case "xai": case "fireworks": case "meta": + case "vercel": return t("ProviderSourceApiShort"); case "kiro": return t("ProviderSourceKiroEnvShort"); diff --git a/apps/desktop-tauri/src/test/providerCatalog.ts b/apps/desktop-tauri/src/test/providerCatalog.ts index dda4c24441..9458b3b9d3 100644 --- a/apps/desktop-tauri/src/test/providerCatalog.ts +++ b/apps/desktop-tauri/src/test/providerCatalog.ts @@ -51,6 +51,7 @@ export const TEST_PROVIDER_CATALOG: Array<[string, string]> = [ ["crof", "Crof"], ["stepfun", "StepFun"], ["venice", "Venice"], + ["vercel", "Vercel AI Gateway"], ["openaiapi", "OpenAI API"], ["grok", "Grok"], ["elevenlabs", "ElevenLabs"], diff --git a/rust/src/core/provider.rs b/rust/src/core/provider.rs index cc954839f7..2b0a8bf158 100755 --- a/rust/src/core/provider.rs +++ b/rust/src/core/provider.rs @@ -93,6 +93,7 @@ pub enum ProviderId { Muse, Replicate, Nous, + Vercel, } impl ProviderId { @@ -179,6 +180,7 @@ impl ProviderId { ProviderId::Muse, ProviderId::Replicate, ProviderId::Nous, + ProviderId::Vercel, ] } @@ -226,6 +228,7 @@ impl ProviderId { ProviderId::Meta => "meta", ProviderId::Muse => "muse", ProviderId::Nous => "nous", + ProviderId::Vercel => "vercel", ProviderId::AiAnd => "aiand", ProviderId::Windsurf => "windsurf", ProviderId::Manus => "manus", @@ -313,6 +316,7 @@ impl ProviderId { ProviderId::Meta => "Meta", ProviderId::Muse => "Muse Code", ProviderId::Nous => "Nous Portal", + ProviderId::Vercel => "Vercel AI Gateway", ProviderId::AiAnd => "ai&", ProviderId::Windsurf => "Windsurf", ProviderId::Manus => "Manus", @@ -415,6 +419,7 @@ impl ProviderId { ProviderId::Meta => None, ProviderId::Muse => None, ProviderId::Nous => None, + ProviderId::Vercel => None, ProviderId::AiAnd => None, ProviderId::Windsurf => None, ProviderId::Doubao => None, @@ -495,6 +500,9 @@ impl ProviderId { "fireworks" | "fireworks-ai" | "fw" => Some(ProviderId::Fireworks), "muse" | "muse-code" | "muse code" => Some(ProviderId::Muse), "nous" | "nous-portal" | "nous portal" | "hermes" => Some(ProviderId::Nous), + "vercel" | "vercel-ai-gateway" | "vercel ai gateway" | "ai-gateway" | "ai gateway" => { + Some(ProviderId::Vercel) + } "meta" | "metaspark" | "meta-spark" | "muse-spark" | "musespark" | "muse spark" | "meta muse spark" => Some(ProviderId::Meta), "aiand" | "ai&" | "ai-and" | "ai and" => Some(ProviderId::AiAnd), @@ -958,6 +966,10 @@ pub fn cli_name_map() -> HashMap<&'static str, ProviderId> { map.insert("nous-portal", ProviderId::Nous); map.insert("nous portal", ProviderId::Nous); map.insert("hermes", ProviderId::Nous); + map.insert("vercel-ai-gateway", ProviderId::Vercel); + map.insert("vercel ai gateway", ProviderId::Vercel); + map.insert("ai-gateway", ProviderId::Vercel); + map.insert("ai gateway", ProviderId::Vercel); map.insert("metaspark", ProviderId::Meta); map.insert("meta-spark", ProviderId::Meta); map.insert("muse-spark", ProviderId::Meta); @@ -1106,6 +1118,8 @@ pub fn brand_color(id: ProviderId) -> &'static str { ProviderId::Muse => "#0668E1", ProviderId::Replicate => "#000000", ProviderId::Nous => "#D6A55C", + // Upstream uses white; a mid neutral keeps contrast on light and dark surfaces. + ProviderId::Vercel => "#737373", } } @@ -1120,7 +1134,7 @@ mod tests { #[test] fn test_provider_id_all() { let all = ProviderId::all(); - assert_eq!(all.len(), 80); + assert_eq!(all.len(), 81); assert!(all.contains(&ProviderId::Claude)); assert!(all.contains(&ProviderId::Codex)); assert!(all.contains(&ProviderId::Pi)); @@ -1181,6 +1195,7 @@ mod tests { assert!(all.contains(&ProviderId::Replicate)); assert!(all.contains(&ProviderId::Muse)); assert!(all.contains(&ProviderId::Nous)); + assert!(all.contains(&ProviderId::Vercel)); } #[test] diff --git a/rust/src/core/provider_factory.rs b/rust/src/core/provider_factory.rs index 712c1d4fb9..3094ebb337 100644 --- a/rust/src/core/provider_factory.rs +++ b/rust/src/core/provider_factory.rs @@ -20,9 +20,9 @@ use crate::providers::{ NousProvider, OllamaProvider, OpenAIApiProvider, OpenCodeGoProvider, OpenCodeProvider, OpenRouterProvider, PerplexityProvider, PiProvider, PoeProvider, QoderProvider, QwenCloudProvider, ReplicateProvider, SakanaProvider, StepFunProvider, Sub2ApiProvider, - T3ChatProvider, TypeSafeProvider, V0Provider, VeniceProvider, VertexAIProvider, WarpProvider, - WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, ZenMuxProvider, - ZoomMateProvider, + T3ChatProvider, TypeSafeProvider, V0Provider, VeniceProvider, VercelProvider, VertexAIProvider, + WarpProvider, WayfinderProvider, WindsurfProvider, XaiProvider, ZaiProvider, ZedProvider, + ZenMuxProvider, ZoomMateProvider, }; /// Instantiate the concrete [`Provider`] implementation for a given [`ProviderId`]. @@ -111,6 +111,7 @@ pub fn instantiate(id: ProviderId) -> Box { ProviderId::Meta => Box::new(MetaProvider::new()), ProviderId::Muse => Box::new(MuseProvider::new()), ProviderId::Nous => Box::new(NousProvider::new()), + ProviderId::Vercel => Box::new(VercelProvider::new()), } } diff --git a/rust/src/core/token_accounts.rs b/rust/src/core/token_accounts.rs index 6dedc0fbde..62e56ea54b 100755 --- a/rust/src/core/token_accounts.rs +++ b/rust/src/core/token_accounts.rs @@ -377,7 +377,8 @@ impl TokenAccountSupport { | ProviderId::Fireworks | ProviderId::Meta | ProviderId::Nous - | ProviderId::Muse => None, + | ProviderId::Muse + | ProviderId::Vercel => None, } } diff --git a/rust/src/providers/mod.rs b/rust/src/providers/mod.rs index de9d265f6e..bab539330d 100755 --- a/rust/src/providers/mod.rs +++ b/rust/src/providers/mod.rs @@ -80,6 +80,7 @@ pub mod t3chat; pub mod typesafe; pub mod v0; pub mod venice; +pub mod vercel; pub mod vertexai; pub mod warp; pub mod wayfinder; @@ -162,6 +163,7 @@ pub use t3chat::T3ChatProvider; pub use typesafe::TypeSafeProvider; pub use v0::V0Provider; pub use venice::VeniceProvider; +pub use vercel::VercelProvider; pub use vertexai::VertexAIProvider; pub use warp::WarpProvider; pub use wayfinder::WayfinderProvider; diff --git a/rust/src/providers/vercel/mod.rs b/rust/src/providers/vercel/mod.rs new file mode 100644 index 0000000000..b509a8c72e --- /dev/null +++ b/rust/src/providers/vercel/mod.rs @@ -0,0 +1,221 @@ +//! Vercel AI Gateway credit balance provider. +//! +//! `GET https://ai-gateway.vercel.sh/v1/credits` with a bearer API key returns +//! the team's remaining balance and lifetime spend as decimal strings +//! (upstream CodexBar 0.66.0, `Resources/Plugins/vercel.js`). The endpoint +//! reports no quota or billing period, so the primary window is informational +//! and no percentage is invented. + +use async_trait::async_trait; +use reqwest::{Client, StatusCode, redirect::Policy}; +use serde::Deserialize; +use std::time::Duration; + +use crate::core::{ + CostSnapshot, FetchContext, Provider, ProviderDisplayDetail, ProviderError, + ProviderFetchResult, ProviderId, ProviderMetadata, RateWindow, SourceMode, UsageSnapshot, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const CREDITS_URL: &str = "https://ai-gateway.vercel.sh/v1/credits"; +const CREDENTIAL_TARGET: &str = "codexbar-vercel"; +const ENV_KEYS: &[&str] = &["AI_GATEWAY_API_KEY"]; +const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const SECTION_LABEL: &str = "Team credits"; + +pub struct VercelProvider { + metadata: ProviderMetadata, + client: Client, + credits_url: String, +} + +impl VercelProvider { + pub fn new() -> Self { + let client = crate::core::credentialed_http_client_builder() + .redirect(Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| Client::new()); + Self::with_client(CREDITS_URL, client) + } + + fn with_client(credits_url: impl Into, client: Client) -> Self { + Self { + metadata: ProviderMetadata { + id: ProviderId::Vercel, + display_name: "Vercel AI Gateway", + session_label: "Balance", + weekly_label: "Balance", + supports_opus: false, + supports_credits: false, + default_enabled: false, + is_primary: false, + dashboard_url: Some("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway"), + status_page_url: None, + tertiary_label_key: None, + }, + client, + credits_url: credits_url.into(), + } + } + + async fn fetch_credits( + &self, + ctx: &FetchContext, + ) -> Result { + let api_key = + crate::providers::resolve_api_key(ctx.api_key.as_deref(), CREDENTIAL_TARGET, ENV_KEYS)?; + let response = self + .client + .get(&self.credits_url) + .bearer_auth(api_key.trim()) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await?; + let status = response.status(); + if status != StatusCode::OK { + return Err(status_error(status)); + } + let body = read_bounded_response(response, MAX_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::Read(error) => ProviderError::Network(error), + BoundedBodyError::TooLarge => parse_failure(), + })?; + Ok(build_result(parse_credits(&body)?)) + } +} + +impl Default for VercelProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for VercelProvider { + fn id(&self) -> ProviderId { + ProviderId::Vercel + } + + fn metadata(&self) -> &ProviderMetadata { + &self.metadata + } + + async fn fetch_usage(&self, ctx: &FetchContext) -> Result { + match ctx.source_mode { + SourceMode::Auto | SourceMode::OAuth => self.fetch_credits(ctx).await, + SourceMode::Web | SourceMode::Cli => { + Err(ProviderError::UnsupportedSource(ctx.source_mode)) + } + } + } + + fn available_sources(&self) -> Vec { + vec![SourceMode::Auto, SourceMode::OAuth] + } +} + +/// Wire shape at upstream v0.66.0: both amounts are decimal strings. +#[derive(Debug, Deserialize)] +struct CreditsResponse { + balance: String, + total_used: String, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct Credits { + balance: f64, + total_used: f64, +} + +/// Status mapping keeps the code but never the response body. +fn status_error(status: StatusCode) -> ProviderError { + let code = status.as_u16(); + match status { + StatusCode::UNAUTHORIZED => ProviderError::AuthRequired, + StatusCode::FORBIDDEN => ProviderError::Other(format!( + "Vercel AI Gateway returned HTTP {code}; the API key lacks permission to read credits." + )), + StatusCode::TOO_MANY_REQUESTS => ProviderError::Other(format!( + "Vercel AI Gateway rate limit reached (HTTP {code})." + )), + status if status.is_server_error() => { + ProviderError::Other(format!("Vercel AI Gateway is unavailable (HTTP {code}).")) + } + _ => ProviderError::Other(format!("Vercel AI Gateway returned HTTP {code}.")), + } +} + +/// Fixed message: response data (and serde's value-echoing errors) never +/// reach the user-facing error. +fn parse_failure() -> ProviderError { + ProviderError::Parse("Vercel AI Gateway returned an unrecognized credit balance.".into()) +} + +fn parse_credits(body: &[u8]) -> Result { + let response: CreditsResponse = serde_json::from_slice(body).map_err(|_| parse_failure())?; + let balance = parse_amount(&response.balance).ok_or_else(parse_failure)?; + let total_used = parse_amount(&response.total_used).ok_or_else(parse_failure)?; + if total_used < 0.0 { + return Err(parse_failure()); + } + Ok(Credits { + balance, + total_used, + }) +} + +/// Accept only `^-?\d+(?:\.\d+)?$` that converts to a finite number. +fn parse_amount(value: &str) -> Option { + let digits = value.strip_prefix('-').unwrap_or(value); + let (integer, fraction) = match digits.split_once('.') { + Some((integer, fraction)) => (integer, Some(fraction)), + None => (digits, None), + }; + let all_digits = |part: &str| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()); + if !all_digits(integer) || fraction.is_some_and(|part| !all_digits(part)) { + return None; + } + value + .parse::() + .ok() + .filter(|amount| amount.is_finite()) +} + +/// `$95.50`, `-$1.25`, `$0.00`; a negative that rounds to zero carries no sign. +fn format_usd(value: f64) -> String { + let cents = format!("{:.2}", value.abs()); + if value < 0.0 && cents != "0.00" { + format!("-${cents}") + } else { + format!("${cents}") + } +} + +fn build_result(credits: Credits) -> ProviderFetchResult { + let usage = + UsageSnapshot::new(RateWindow::informational(SECTION_LABEL)).with_login_method("API"); + // `with_balance` clamps to >= 0, so a negative balance is left untyped and + // stays visible only through the signed display row below. + let mut cost = CostSnapshot::new(credits.total_used, "USD", SECTION_LABEL); + if credits.balance >= 0.0 { + cost = cost.with_balance(credits.balance); + } + ProviderFetchResult::new(usage, "api") + .with_cost(cost) + .with_display_detail(ProviderDisplayDetail::new( + "vercel-balance", + "Available balance", + format_usd(credits.balance), + )) + .with_display_detail(ProviderDisplayDetail::new( + "vercel-lifetime-spend", + "Lifetime spend", + format_usd(credits.total_used), + )) +} + +#[cfg(test)] +mod tests; diff --git a/rust/src/providers/vercel/tests.rs b/rust/src/providers/vercel/tests.rs new file mode 100644 index 0000000000..780aa0b138 --- /dev/null +++ b/rust/src/providers/vercel/tests.rs @@ -0,0 +1,295 @@ +use super::*; +use std::io::{Read, Write}; +use std::net::TcpListener; + +/// Upstream fixture shape (`APIBalancePluginTests.swift`). +fn credits_payload(balance: &str) -> String { + format!(r#"{{"balance":"{balance}","total_used":"4.50"}}"#) +} + +fn assert_parse_failure(result: Result) { + match result { + Err(ProviderError::Parse(message)) => { + assert!(!message.contains("private-response"), "{message}"); + } + other => panic!("expected parse failure, got {other:?}"), + } +} + +#[test] +fn parses_upstream_fixture_into_typed_amounts() { + let credits = parse_credits(credits_payload("95.50").as_bytes()).unwrap(); + assert_eq!(credits.balance, 95.5); + assert_eq!(credits.total_used, 4.5); +} + +#[test] +fn accepts_zero_negative_and_unpadded_balances() { + for (value, expected) in [("0.00", 0.0), ("-1.250000", -1.25), ("7", 7.0)] { + let credits = parse_credits(credits_payload(value).as_bytes()).unwrap(); + assert_eq!(credits.balance, expected, "{value}"); + } +} + +#[test] +fn rejects_malformed_amounts_without_echoing_response_data() { + for value in ["", " ", "NaN", "1e999", "0x10", "private-response"] { + assert_parse_failure(parse_credits(credits_payload(value).as_bytes())); + } + // Decimal strings that overflow f64 are not finite and must fail too. + assert_parse_failure(parse_credits(credits_payload(&"9".repeat(400)).as_bytes())); + for value in ["+1", ".5", "1.", "1e3", "Infinity", "1.2.3", "-", "--1"] { + assert_parse_failure(parse_credits(credits_payload(value).as_bytes())); + } +} + +#[test] +fn rejects_wrong_shapes_without_echoing_response_data() { + for body in [ + "private-response", + "{}", + "null", + "", + r#"{"balance":12,"total_used":"0"}"#, + r#"{"balance":"1.00","total_used":0}"#, + r#"{"balance":"1.00"}"#, + r#"{"total_used":"1.00"}"#, + r#"{"balance":null,"total_used":"1.00"}"#, + r#"["private-response"]"#, + ] { + assert_parse_failure(parse_credits(body.as_bytes())); + } +} + +#[test] +fn rejects_negative_lifetime_spend() { + assert_parse_failure(parse_credits(br#"{"balance":"1.00","total_used":"-1.00"}"#)); +} + +#[test] +fn ignores_unknown_fields_without_inventing_them() { + let credits = + parse_credits(br#"{"balance":"1.00","total_used":"2.00","extra":"private-response"}"#) + .unwrap(); + assert_eq!(credits.balance, 1.0); + assert_eq!(credits.total_used, 2.0); +} + +#[test] +fn formats_usd_with_sign_before_the_symbol() { + assert_eq!(format_usd(95.5), "$95.50"); + assert_eq!(format_usd(-1.25), "-$1.25"); + assert_eq!(format_usd(0.0), "$0.00"); + assert_eq!(format_usd(-0.001), "$0.00"); + assert_eq!(format_usd(4.5), "$4.50"); +} + +#[test] +fn maps_statuses_without_response_bodies() { + assert!(matches!( + status_error(StatusCode::UNAUTHORIZED), + ProviderError::AuthRequired + )); + for (status, needle) in [ + (StatusCode::FORBIDDEN, "HTTP 403"), + (StatusCode::TOO_MANY_REQUESTS, "rate limit"), + (StatusCode::SERVICE_UNAVAILABLE, "unavailable"), + (StatusCode::INTERNAL_SERVER_ERROR, "unavailable"), + (StatusCode::BAD_REQUEST, "HTTP 400"), + ] { + match status_error(status) { + ProviderError::Other(message) => { + assert!(message.contains(needle), "{status}: {message}"); + assert!(message.contains(&status.as_u16().to_string()), "{message}"); + } + other => panic!("{status}: unexpected {other:?}"), + } + } +} + +#[test] +fn balance_result_is_informational_with_typed_balance_and_rows() { + let result = build_result(Credits { + balance: 95.5, + total_used: 4.5, + }); + assert!(result.usage.primary.is_informational); + assert!(result.usage.secondary.is_none()); + assert_eq!(result.usage.login_method.as_deref(), Some("API")); + let cost = result.cost.as_ref().expect("typed cost"); + assert_eq!(cost.balance, Some(95.5)); + assert_eq!(cost.used, 4.5); + assert_eq!(cost.currency_code, "USD"); + assert!(!cost.always_visible, "lifetime spend is not a 30-day spend"); + let rows: Vec<_> = result + .display_details() + .iter() + .map(|row| (row.title(), row.value())) + .collect(); + assert_eq!( + rows, + [("Available balance", "$95.50"), ("Lifetime spend", "$4.50")] + ); +} + +#[test] +fn zero_balance_is_a_typed_zero_and_negative_stays_display_only() { + let zero = build_result(Credits { + balance: 0.0, + total_used: 4.5, + }); + assert_eq!(zero.cost.as_ref().unwrap().balance, Some(0.0)); + assert_eq!(zero.display_details()[0].value(), "$0.00"); + + let negative = build_result(Credits { + balance: -1.25, + total_used: 4.5, + }); + assert_eq!(negative.cost.as_ref().unwrap().balance, None); + assert_eq!(negative.display_details()[0].value(), "-$1.25"); +} + +#[test] +fn metadata_matches_upstream_descriptor() { + let provider = VercelProvider::new(); + let metadata = provider.metadata(); + assert_eq!(metadata.display_name, "Vercel AI Gateway"); + assert_eq!(metadata.session_label, "Balance"); + assert_eq!(metadata.weekly_label, "Balance"); + assert!(!metadata.default_enabled); + assert_eq!( + metadata.dashboard_url, + Some("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway") + ); + assert_eq!(ProviderId::Vercel.cli_name(), "vercel"); + assert_eq!( + ProviderId::from_cli_name("vercel-ai-gateway"), + Some(ProviderId::Vercel) + ); + assert_eq!( + provider.available_sources(), + [SourceMode::Auto, SourceMode::OAuth] + ); +} + +#[tokio::test] +async fn unsupported_sources_fail_before_any_request() { + let provider = VercelProvider::with_client("http://127.0.0.1:1/v1/credits", Client::new()); + let context = FetchContext { + source_mode: SourceMode::Web, + api_key: Some("test-key".into()), + ..FetchContext::default() + }; + assert!(matches!( + provider.fetch_usage(&context).await, + Err(ProviderError::UnsupportedSource(SourceMode::Web)) + )); +} + +/// One-shot local server; returns the lower-cased request head it received. +fn serve_once( + status_line: &'static str, + body: String, +) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind local test server"); + let address = listener.local_addr().expect("local server address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).expect("read request"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + write!( + stream, + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write response"); + request + }); + (format!("http://{address}/v1/credits"), server) +} + +fn test_provider(url: String) -> VercelProvider { + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + VercelProvider::with_client(url, client) +} + +fn keyed_context() -> FetchContext { + FetchContext { + api_key: Some(" test-gateway-key ".into()), + ..FetchContext::default() + } +} + +#[tokio::test] +async fn sends_trimmed_bearer_request_and_reports_balance() { + let (url, server) = serve_once("200 OK", credits_payload("-3.25")); + let result = test_provider(url) + .fetch_usage(&keyed_context()) + .await + .expect("credits fetch"); + let request = server.join().expect("test server thread"); + assert!(request.starts_with("get /v1/credits "), "{request}"); + assert!(request.contains("authorization: bearer test-gateway-key\r\n")); + assert_eq!(result.source_label, "api"); + assert_eq!(result.display_details()[0].value(), "-$3.25"); + assert_eq!(result.display_details()[1].value(), "$4.50"); + assert!(result.usage.primary.is_informational); +} + +#[tokio::test] +async fn http_failures_expose_status_but_never_the_body() { + for (status_line, needle) in [ + ("401 Unauthorized", "Authentication required"), + ("403 Forbidden", "HTTP 403"), + ("429 Too Many Requests", "rate limit"), + ("503 Service Unavailable", "unavailable"), + ("400 Bad Request", "HTTP 400"), + ] { + let (url, server) = serve_once(status_line, "private-response".into()); + let error = test_provider(url) + .fetch_usage(&keyed_context()) + .await + .expect_err(status_line); + server.join().expect("test server thread"); + let message = error.to_string(); + assert!(message.contains(needle), "{status_line}: {message}"); + assert!(!message.contains("private-response"), "{message}"); + } +} + +#[tokio::test] +async fn redirects_are_not_followed_with_the_api_key() { + let (url, server) = serve_once( + "302 Found\r\nLocation: http://127.0.0.1:9/steal", + String::new(), + ); + let error = test_provider(url) + .fetch_usage(&keyed_context()) + .await + .expect_err("redirect is not a success"); + server.join().expect("test server thread"); + assert!(error.to_string().contains("HTTP 302"), "{error}"); +} + +#[tokio::test] +async fn malformed_success_body_is_a_parse_error() { + let (url, server) = serve_once("200 OK", "private-response".into()); + let error = test_provider(url) + .fetch_usage(&keyed_context()) + .await + .expect_err("malformed body"); + server.join().expect("test server thread"); + assert!(matches!(error, ProviderError::Parse(_)), "{error}"); + assert!(!error.to_string().contains("private-response")); +} + +#[test] +fn missing_key_uses_the_shared_not_installed_error() { + let result = + crate::providers::resolve_api_key(None, "codexbar-vercel-test-without-credential", &[]); + assert!(matches!(result, Err(ProviderError::NotInstalled(_)))); +} diff --git a/rust/src/settings/api_keys.rs b/rust/src/settings/api_keys.rs index c2adf3565c..09a921c725 100644 --- a/rust/src/settings/api_keys.rs +++ b/rust/src/settings/api_keys.rs @@ -426,6 +426,17 @@ pub fn get_api_key_providers() -> Vec { config_file_path: None, dashboard_url: Some("https://portal.neuralwatt.com/dashboard"), }, + ProviderConfigInfo { + id: ProviderId::Vercel, + name: "Vercel AI Gateway", + requires_api_key: true, + api_key_env_var: Some("AI_GATEWAY_API_KEY"), + api_key_help: Some( + "Add a Vercel AI Gateway API key to show the team's credit balance and lifetime spend.", + ), + config_file_path: None, + dashboard_url: Some("https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway"), + }, ProviderConfigInfo { id: ProviderId::Doubao, name: "Doubao / Volcengine Ark",