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",