From 36edf01511e68ef2c9f757e6cf43dd217fd70806 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:17:02 +0700 Subject: [PATCH 1/4] Add opt-in Vercel personal AI Gateway budget With a Vercel access token configured, the Vercel provider reads the token user's AI Gateway budget from api.vercel.com instead of the team credits endpoint. Without a token the credits path is unchanged. - Identify the user with /v2/user, then page through /ai-gateway/budgets/list (scopeType=user, at most 20 pages). - Match the native user ID or its usr_ form; a custom budget wins over an inherited default; two applicable budgets fail the refresh. - Daily, weekly and monthly budgets reset at UTC boundaries; none is cumulative with no reset. - The access token is read from the provider's stored secret, else VERCEL_ACCESS_TOKEN, and is only sent to api.vercel.com. The Team ID comes from the provider's Team ID setting, else VERCEL_TEAM_ID. --- rust/src/providers/vercel/budget.rs | 363 +++++++++++++++ rust/src/providers/vercel/budget_tests.rs | 514 ++++++++++++++++++++++ rust/src/providers/vercel/mod.rs | 61 ++- 3 files changed, 930 insertions(+), 8 deletions(-) create mode 100644 rust/src/providers/vercel/budget.rs create mode 100644 rust/src/providers/vercel/budget_tests.rs diff --git a/rust/src/providers/vercel/budget.rs b/rust/src/providers/vercel/budget.rs new file mode 100644 index 00000000000..147e86f18ff --- /dev/null +++ b/rust/src/providers/vercel/budget.rs @@ -0,0 +1,363 @@ +//! Opt-in personal AI Gateway budget (upstream CodexBar 0.79 `vercel-budget.js`, +//! #4623 and #4650). +//! +//! With a Vercel access token configured, the provider identifies the token's +//! user with `GET https://api.vercel.com/v2/user`, then reads the team's +//! budgets with `GET https://api.vercel.com/ai-gateway/budgets/list` (bounded +//! pagination, `scopeType=user`). The budget is matched to the user's native +//! ID or its `usr_`-prefixed form. A custom budget wins over an inherited +//! default. Daily, weekly and monthly budgets reset at the documented UTC +//! boundaries; `none` never resets. The AI Gateway key is never sent here, and +//! the access token is never sent to `ai-gateway.vercel.sh`. + +use std::collections::HashSet; + +use chrono::{DateTime, Datelike, Days, Months, NaiveDate, Utc}; +use reqwest::StatusCode; +use serde_json::Value; + +use super::{TEAM_ENV, VercelProvider}; +use crate::core::{ + FetchContext, ProviderDisplayDetail, ProviderError, ProviderFetchResult, RateWindow, + UsageSnapshot, format_currency, +}; +use crate::providers::{BoundedBodyError, read_bounded_response}; + +const MAX_BUDGET_PAGES: usize = 20; +const PAGE_LIMIT: &str = "200"; +const MAX_BUDGET_RESPONSE_BYTES: usize = 1024 * 1024; +const BUDGET_SECTION: &str = "Personal budget"; +const LOGIN_METHOD: &str = "Access token"; +const NOT_CONFIGURED_LABEL: &str = "No personal budget configured"; +const TEAM_HINT: &str = + "Set the Vercel Team ID in Settings or VERCEL_TEAM_ID to read your personal budget."; +const TEAM_FORMAT: &str = "Invalid Vercel Team ID. Enter the team ID or slug without spaces."; +const UNRECOGNIZED: &str = "Vercel returned an unrecognized personal budget."; + +/// The access token from settings, else `VERCEL_ACCESS_TOKEN`. Blank values +/// count as unconfigured, so the team-credit path keeps running. +pub(super) fn pick_access_token(stored: Option<&str>, env: Option<&str>) -> Option { + [stored, env] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()) + .map(str::to_owned) +} + +/// The Team ID from the provider's Team ID setting, else `VERCEL_TEAM_ID`, +/// validated before it is placed in a query string. +pub(super) fn resolve_team( + configured: Option<&str>, + env: Option<&str>, +) -> Result { + let raw = [configured, env] + .into_iter() + .flatten() + .map(str::trim) + .find(|value| !value.is_empty()); + let Some(team) = raw else { + return Err(ProviderError::Other(TEAM_HINT.into())); + }; + if !is_team_id(team) { + return Err(ProviderError::Other(TEAM_FORMAT.into())); + } + Ok(team.to_string()) +} + +fn is_team_id(value: &str) -> bool { + value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-') +} + +/// Fixed message only: response data never reaches the user-facing error. +fn parse_failure() -> ProviderError { + ProviderError::Parse(UNRECOGNIZED.into()) +} + +/// 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::Other( + "Vercel rejected the access token. Check or replace it in Settings.".into(), + ), + StatusCode::FORBIDDEN => ProviderError::Other(format!( + "Vercel returned HTTP {code}; the token's user needs a non-Contributor role on this team to read budgets." + )), + StatusCode::TOO_MANY_REQUESTS => { + ProviderError::Other(format!("Vercel rate limit reached (HTTP {code}).")) + } + status if status.is_server_error() => { + ProviderError::Other(format!("Vercel is unavailable (HTTP {code}).")) + } + _ => ProviderError::Other(format!("Vercel returned HTTP {code}.")), + } +} + +/// The token's user, from `GET /v2/user`. +#[derive(Debug, PartialEq, Eq)] +struct BudgetUser { + id: String, + email: Option, +} + +fn parse_user(body: &Value) -> Result { + let user = body.get("user").ok_or_else(parse_failure)?; + let id = user + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or_else(parse_failure)?; + let email = user.get("email").and_then(Value::as_str).map(str::to_owned); + Ok(BudgetUser { + id: id.to_owned(), + email, + }) +} + +/// Whether a budget row applies to this user and is active. Rows scoped to +/// someone else are skipped; a row that applies with malformed flags fails the +/// whole refresh. +fn matching_budget<'a>( + budget: &'a Value, + user_id: &str, +) -> Result, ProviderError> { + if budget.get("scopeType").and_then(Value::as_str) != Some("user") { + return Ok(None); + } + let prefixed = format!("usr_{user_id}"); + let scope_matches = matches!( + budget.get("scopeId").and_then(Value::as_str), + Some(id) if id == user_id || id == prefixed + ); + if !scope_matches { + return Ok(None); + } + let active = budget + .get("active") + .and_then(Value::as_bool) + .ok_or_else(parse_failure)?; + let archived = budget + .get("archived") + .and_then(Value::as_bool) + .ok_or_else(parse_failure)?; + match budget.get("source") { + None => {} + Some(Value::String(source)) if source == "default" => {} + Some(_) => return Err(parse_failure()), + } + Ok((active && !archived).then_some(budget)) +} + +/// Custom budgets (no `source`) take precedence over inherited defaults. Two +/// budgets that both apply to the user are ambiguous and fail the refresh. +fn effective_budget(matches: &[Value]) -> Result, ProviderError> { + let custom: Vec<&Value> = matches + .iter() + .filter(|budget| budget.get("source").is_none()) + .collect(); + let effective: Vec<&Value> = if custom.is_empty() { + matches.iter().collect() + } else { + custom + }; + match effective.as_slice() { + [] => Ok(None), + [only] => Ok(Some(*only)), + _ => Err(parse_failure()), + } +} + +/// Start and end of the current budget period, in UTC. +type PeriodBounds = (DateTime, DateTime); + +/// UTC reset boundaries for a refresh period. `Ok(None)` for a cumulative +/// (`none`) budget, which has no reset. +fn period_bounds(period: &str, now: DateTime) -> Result, ProviderError> { + let today = now.date_naive(); + let (start, end): (NaiveDate, NaiveDate) = match period { + "daily" => (today, today.succ_opt().ok_or_else(parse_failure)?), + "weekly" => { + let start = today - Days::new(u64::from(today.weekday().num_days_from_monday())); + (start, start + Days::new(7)) + } + "monthly" => { + let start = today.with_day(1).ok_or_else(parse_failure)?; + let end = start + .checked_add_months(Months::new(1)) + .ok_or_else(parse_failure)?; + (start, end) + } + "none" => return Ok(None), + _ => return Err(parse_failure()), + }; + let to_utc = |date: NaiveDate| { + date.and_hms_opt(0, 0, 0) + .map(|midnight| midnight.and_utc()) + .ok_or_else(parse_failure) + }; + Ok(Some((to_utc(start)?, to_utc(end)?))) +} + +fn finite_number(value: Option<&Value>) -> Option { + value + .and_then(Value::as_f64) + .filter(|value| value.is_finite()) +} + +fn budget_row(id: &str, label: &str, value: String) -> Option { + ProviderDisplayDetail::new(format!("vercel-budget-{id}"), label, value)? + .with_section_title(BUDGET_SECTION) +} + +/// Builds the card from the effective budget, or the unconfigured state when +/// the user has none. The percentage and reset are only present when the +/// budget has a limit and a refresh period. +fn build_result( + budget: Option<&Value>, + team: &str, + user: &BudgetUser, + now: DateTime, +) -> Result { + let (primary, rows) = match budget { + None => ( + RateWindow::informational(BUDGET_SECTION), + vec![budget_row("status", NOT_CONFIGURED_LABEL, "—".into())], + ), + Some(budget) => { + let limit = finite_number(budget.get("limitAmount")) + .filter(|limit| *limit > 0.0) + .ok_or_else(parse_failure)?; + let spent = finite_number(budget.get("currentSpend")) + .filter(|spent| *spent >= 0.0) + .ok_or_else(parse_failure)?; + let period = budget + .get("refreshPeriod") + .and_then(Value::as_str) + .ok_or_else(parse_failure)?; + let bounds = period_bounds(period, now)?; + let spent_text = format_currency(spent, "USD"); + let limit_text = format_currency(limit, "USD"); + let remaining_text = format_currency((limit - spent).max(0.0), "USD"); + + let mut window = RateWindow::new(spent / limit * 100.0); + window.reset_description = Some(format!("{spent_text} / {limit_text}")); + window.description_is_detail = true; + if let Some((start, end)) = bounds { + window.window_minutes = u32::try_from((end - start).num_minutes()).ok(); + window.resets_at = Some(end); + } + let rows = vec![ + budget_row("spend", "Spend", spent_text), + budget_row("limit", "Limit", limit_text), + budget_row("remaining", "Remaining", remaining_text), + ]; + (window, rows) + } + }; + + let mut usage = UsageSnapshot::new(primary) + .with_primary_label(BUDGET_SECTION) + .with_login_method(LOGIN_METHOD) + .with_organization(team); + if let Some(email) = user.email.as_deref() { + usage = usage.with_email(email); + } + Ok(ProviderFetchResult::new(usage, "api").with_display_details(rows.into_iter().flatten())) +} + +impl VercelProvider { + /// Personal budget path. `token` is the Vercel access token and is sent + /// only to `api_base_url`. + pub(super) async fn fetch_personal_budget( + &self, + ctx: &FetchContext, + token: &str, + now: DateTime, + ) -> Result { + let team_env = std::env::var(TEAM_ENV).ok(); + let team = resolve_team(ctx.workspace_id.as_deref(), team_env.as_deref())?; + let user_url = format!("{}/v2/user", self.api_base_url); + let user = parse_user(&self.get_budget_json(&user_url, token, &[]).await?)?; + + let budgets_url = format!("{}/ai-gateway/budgets/list", self.api_base_url); + let mut matches: Vec = Vec::new(); + let mut seen_cursors: HashSet = HashSet::new(); + let mut cursor: Option = None; + let mut finished = false; + for page in 0..MAX_BUDGET_PAGES { + let mut query = vec![ + ("teamId", team.as_str()), + ("scopeType", "user"), + ("limit", PAGE_LIMIT), + ]; + if let Some(current) = cursor.as_deref() { + query.push(("cursor", current)); + } + let body = self.get_budget_json(&budgets_url, token, &query).await?; + let rows = body + .get("budgets") + .and_then(Value::as_array) + .ok_or_else(parse_failure)?; + for row in rows { + if let Some(budget) = matching_budget(row, &user.id)? { + matches.push(budget.clone()); + } + } + match body.get("nextCursor") { + None | Some(Value::Null) => { + finished = true; + break; + } + Some(Value::String(next)) + if !next.is_empty() + && page + 1 < MAX_BUDGET_PAGES + && seen_cursors.insert(next.clone()) => + { + cursor = Some(next.clone()); + } + Some(_) => return Err(parse_failure()), + } + } + if !finished { + return Err(parse_failure()); + } + + let budget = effective_budget(&matches)?; + build_result(budget, &team, &user, now) + } + + async fn get_budget_json( + &self, + url: &str, + token: &str, + query: &[(&str, &str)], + ) -> Result { + let response = self + .client + .get(url) + .query(query) + .bearer_auth(token) + .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_BUDGET_RESPONSE_BYTES) + .await + .map_err(|error| match error { + BoundedBodyError::Read(error) => ProviderError::Network(error), + BoundedBodyError::TooLarge => parse_failure(), + })?; + serde_json::from_slice(&body).map_err(|_| parse_failure()) + } +} + +#[cfg(test)] +#[path = "budget_tests.rs"] +mod tests; diff --git a/rust/src/providers/vercel/budget_tests.rs b/rust/src/providers/vercel/budget_tests.rs new file mode 100644 index 00000000000..6a5b5872999 --- /dev/null +++ b/rust/src/providers/vercel/budget_tests.rs @@ -0,0 +1,514 @@ +use super::*; + +use crate::core::Provider; +use chrono::TimeZone; +use reqwest::Client; +use reqwest::redirect::Policy; +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::thread::JoinHandle; + +const TOKEN: &str = "fixture-token"; +const TEAM: &str = "team_fixture"; + +/// Fixed clock: Wednesday 2026-10-14 12:00 UTC. +fn wednesday() -> DateTime { + Utc.with_ymd_and_hms(2026, 10, 14, 12, 0, 0).unwrap() +} + +fn user_body(id: &str) -> String { + json!({"user": {"id": id, "email": "fixture@example.com"}}).to_string() +} + +fn budget_row(scope_id: &str, spend: f64, limit: f64, period: &str) -> Value { + json!({ + "scopeType": "user", + "scopeId": scope_id, + "active": true, + "archived": false, + "refreshPeriod": period, + "limitAmount": limit, + "currentSpend": spend, + }) +} + +fn page(rows: Vec, next: Option<&str>) -> String { + json!({"budgets": rows, "nextCursor": next}).to_string() +} + +/// Serves one scripted response per accepted connection and returns the +/// lower-cased request heads it saw, in order. +fn serve_sequence(responses: Vec<(&'static str, String)>) -> (String, 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 handle = std::thread::spawn(move || { + let mut heads = Vec::new(); + for (status, body) in responses { + let (mut stream, _) = listener.accept().expect("accept request"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + while !buffer.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut chunk).expect("read request"); + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + heads.push(String::from_utf8_lossy(&buffer).to_ascii_lowercase()); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ) + .expect("write response"); + } + heads + }); + (format!("http://{address}"), handle) +} + +fn provider(base: &str) -> VercelProvider { + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + VercelProvider::with_client("http://127.0.0.1:1/v1/credits", client) + .with_api_base_url(base) + .with_access_token_lookup(|| Some(TOKEN.to_owned())) +} + +fn team_context() -> FetchContext { + FetchContext { + workspace_id: Some(TEAM.into()), + ..FetchContext::default() + } +} + +async fn budget( + responses: Vec<(&'static str, String)>, +) -> (Result, Vec) { + let (base, server) = serve_sequence(responses); + let result = provider(&base) + .fetch_personal_budget(&team_context(), TOKEN, wednesday()) + .await; + let heads = server.join().expect("test server thread"); + (result, heads) +} + +async fn budget_ok(responses: Vec<(&'static str, String)>) -> (ProviderFetchResult, Vec) { + let (result, heads) = budget(responses).await; + (result.expect("budget fetch"), heads) +} + +fn assert_parse_failure(result: Result) { + match result { + Err(ProviderError::Parse(message)) => { + assert_eq!(message, UNRECOGNIZED); + } + other => panic!("expected parse failure, got {:?}", other.map(|_| ())), + } +} + +#[test] +fn pick_access_token_prefers_stored_then_env_and_ignores_blanks() { + assert_eq!( + pick_access_token(Some(" stored-token "), Some("env-token")).as_deref(), + Some("stored-token") + ); + assert_eq!( + pick_access_token(Some(" "), Some(" env-token ")).as_deref(), + Some("env-token") + ); + assert_eq!(pick_access_token(None, Some("")), None); + assert_eq!(pick_access_token(None, None), None); +} + +#[test] +fn resolve_team_prefers_setting_then_env_and_validates_the_shape() { + assert_eq!( + resolve_team(Some(" team_fixture "), Some("team_env")).unwrap(), + "team_fixture" + ); + assert_eq!( + resolve_team(Some(" "), Some("team_env")).unwrap(), + "team_env" + ); + assert_eq!(resolve_team(Some("my-team_1"), None).unwrap(), "my-team_1"); + + let missing = resolve_team(None, None).expect_err("team required"); + assert!(matches!(missing, ProviderError::Other(ref hint) if hint.contains("Team ID"))); + + for invalid in ["bad team", "../etc", "team?x=1", &"a".repeat(129)] { + let error = resolve_team(Some(invalid), None).expect_err(invalid); + assert!( + error.to_string().contains("Invalid Vercel Team ID"), + "{invalid}: {error}" + ); + } +} + +#[test] +fn native_user_id_budget_is_matched_and_rendered() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let (result, heads) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![ + budget_row("someoneElse", 999.0, 1000.0, "monthly"), + budget_row("fixtureUser", 30.0, 120.0, "monthly"), + ], + None, + ), + ), + ]) + .await; + + assert_eq!(heads.len(), 2); + assert!(heads[0].starts_with("get /v2/user "), "{}", heads[0]); + assert!( + heads[1].starts_with( + "get /ai-gateway/budgets/list?teamid=team_fixture&scopetype=user&limit=200 " + ), + "{}", + heads[1] + ); + assert!( + heads + .iter() + .all(|head| head.contains("authorization: bearer fixture-token")) + ); + + let primary = &result.usage.primary; + assert!((primary.used_percent - 25.0).abs() < 1e-9); + assert_eq!( + primary.reset_description.as_deref(), + Some("$30.00 / $120.00") + ); + assert!(primary.description_is_detail); + assert_eq!(primary.window_minutes, Some(31 * 24 * 60)); + assert_eq!( + primary.resets_at, + Some(Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap()) + ); + assert_eq!( + result.usage.primary_label.as_deref(), + Some("Personal budget") + ); + let rows: Vec<(&str, &str)> = result + .display_details() + .iter() + .map(|detail| (detail.title(), detail.value())) + .collect(); + assert_eq!( + rows, + [ + ("Spend", "$30.00"), + ("Limit", "$120.00"), + ("Remaining", "$90.00") + ] + ); + assert_eq!(result.usage.login_method.as_deref(), Some("Access token")); + assert_eq!(result.usage.account_organization.as_deref(), Some(TEAM)); + assert_eq!( + result.usage.account_email.as_deref(), + Some("fixture@example.com") + ); + assert_eq!(result.source_label, "api"); + }); +} + +#[tokio::test] +async fn prefixed_user_id_budget_is_matched() { + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![budget_row("usr_fixtureUser", 30.0, 120.0, "monthly")], + None, + ), + ), + ]) + .await; + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("$30.00 / $120.00") + ); +} + +#[tokio::test] +async fn no_matching_budget_is_the_unconfigured_state_not_zero_spend() { + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![budget_row("usr_otherUser", 5.0, 10.0, "monthly")], + None, + ), + ), + ]) + .await; + assert!(result.usage.primary.is_informational); + assert!(!result.usage.primary.usage_known); + let details = result.display_details(); + assert_eq!(details.len(), 1); + assert_eq!(details[0].title(), "No personal budget configured"); + assert_eq!(details[0].value(), "—"); +} + +#[tokio::test] +async fn custom_budget_takes_precedence_over_an_inherited_default() { + let mut default_row = budget_row("fixtureUser", 50.0, 60.0, "monthly"); + default_row["source"] = json!("default"); + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![ + default_row, + budget_row("fixtureUser", 30.0, 120.0, "monthly"), + ], + None, + ), + ), + ]) + .await; + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); +} + +#[tokio::test] +async fn an_inherited_default_applies_when_no_custom_budget_exists() { + let mut default_row = budget_row("fixtureUser", 15.0, 60.0, "monthly"); + default_row["source"] = json!("default"); + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ("200 OK", page(vec![default_row], None)), + ]) + .await; + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); + assert_eq!( + result.usage.primary.reset_description.as_deref(), + Some("$15.00 / $60.00") + ); +} + +#[tokio::test] +async fn two_custom_budgets_for_one_user_are_ambiguous() { + let (result, _) = budget(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![ + budget_row("fixtureUser", 1.0, 10.0, "monthly"), + budget_row("usr_fixtureUser", 2.0, 20.0, "daily"), + ], + None, + ), + ), + ]) + .await; + assert_parse_failure(result); +} + +#[tokio::test] +async fn pagination_follows_the_cursor_and_stops_when_it_is_null() { + let (result, heads) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![budget_row("someoneElse", 1.0, 2.0, "monthly")], + Some("cursor-2"), + ), + ), + ( + "200 OK", + page( + vec![budget_row("fixtureUser", 30.0, 120.0, "monthly")], + None, + ), + ), + ]) + .await; + assert_eq!(heads.len(), 3); + assert!(heads[2].contains("cursor=cursor-2"), "{}", heads[2]); + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); +} + +#[tokio::test] +async fn a_repeated_cursor_fails_the_refresh() { + let (result, _) = budget(vec![ + ("200 OK", user_body("fixtureUser")), + ("200 OK", page(vec![], Some("same"))), + ("200 OK", page(vec![], Some("same"))), + ]) + .await; + assert_parse_failure(result); +} + +#[tokio::test] +async fn pagination_is_capped_at_twenty_pages() { + let mut responses = vec![("200 OK", user_body("fixtureUser"))]; + for index in 0..MAX_BUDGET_PAGES { + let next = format!("cursor-{index}"); + responses.push(("200 OK", page(vec![], Some(&next)))); + } + let (result, heads) = budget(responses).await; + assert_parse_failure(result); + assert_eq!(heads.len(), 1 + MAX_BUDGET_PAGES); +} + +#[tokio::test] +async fn a_matching_row_with_a_non_boolean_active_flag_fails_the_refresh() { + let mut row = budget_row("fixtureUser", 30.0, 120.0, "monthly"); + row["active"] = json!("yes"); + let (result, _) = budget(vec![ + ("200 OK", user_body("fixtureUser")), + ("200 OK", page(vec![row], None)), + ]) + .await; + assert_parse_failure(result); +} + +#[tokio::test] +async fn rows_for_other_scopes_are_ignored_even_when_malformed() { + let other = json!({"scopeType": "team", "scopeId": "fixtureUser", "active": "yes"}); + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ("200 OK", page(vec![other], None)), + ]) + .await; + assert_eq!( + result.display_details()[0].title(), + "No personal budget configured" + ); +} + +#[tokio::test] +async fn http_failures_map_to_fixed_messages_without_the_body() { + for (status_line, needle) in [ + ("401 Unauthorized", "rejected the access token"), + ("403 Forbidden", "non-Contributor role"), + ("429 Too Many Requests", "rate limit"), + ("503 Service Unavailable", "unavailable"), + ("400 Bad Request", "HTTP 400"), + ] { + let (result, _) = budget(vec![(status_line, "private-response".into())]).await; + let error = result.expect_err(status_line); + let message = error.to_string(); + assert!(message.contains(needle), "{status_line}: {message}"); + assert!(!message.contains("private-response"), "{message}"); + } +} + +#[tokio::test] +async fn a_malformed_user_response_is_a_parse_failure() { + let (result, _) = budget(vec![("200 OK", json!({"user": {"id": ""}}).to_string())]).await; + assert_parse_failure(result); +} + +#[test] +fn reset_boundaries_are_documented_utc_periods() { + let now = wednesday(); + let day = period_bounds("daily", now).unwrap().unwrap(); + assert_eq!(day.0, Utc.with_ymd_and_hms(2026, 10, 14, 0, 0, 0).unwrap()); + assert_eq!(day.1, Utc.with_ymd_and_hms(2026, 10, 15, 0, 0, 0).unwrap()); + + // Weekly budgets reset on Monday; a Sunday must map back to the Monday six days earlier. + let sunday = Utc.with_ymd_and_hms(2026, 10, 18, 9, 0, 0).unwrap(); + let week = period_bounds("weekly", sunday).unwrap().unwrap(); + assert_eq!(week.0, Utc.with_ymd_and_hms(2026, 10, 12, 0, 0, 0).unwrap()); + assert_eq!(week.1, Utc.with_ymd_and_hms(2026, 10, 19, 0, 0, 0).unwrap()); + + let month = period_bounds("monthly", now).unwrap().unwrap(); + assert_eq!(month.0, Utc.with_ymd_and_hms(2026, 10, 1, 0, 0, 0).unwrap()); + assert_eq!(month.1, Utc.with_ymd_and_hms(2026, 11, 1, 0, 0, 0).unwrap()); + + assert_eq!(period_bounds("none", now).unwrap(), None); + assert!(matches!( + period_bounds("hourly", now), + Err(ProviderError::Parse(_)) + )); +} + +#[tokio::test] +async fn cumulative_budget_has_no_reset_or_window() { + let mut row = budget_row("fixtureUser", 30.0, 120.0, "none"); + row["refreshPeriod"] = json!("none"); + let (result, _) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ("200 OK", page(vec![row], None)), + ]) + .await; + assert_eq!(result.usage.primary.resets_at, None); + assert_eq!(result.usage.primary.window_minutes, None); + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); +} + +#[tokio::test] +async fn fetch_usage_with_a_token_routes_to_the_budget_and_never_sends_the_gateway_key() { + let (base, server) = serve_sequence(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![budget_row("fixtureUser", 30.0, 120.0, "monthly")], + None, + ), + ), + ]); + let context = FetchContext { + api_key: Some("gateway-key".into()), + workspace_id: Some(TEAM.into()), + ..FetchContext::default() + }; + let result = provider(&base) + .fetch_usage(&context) + .await + .expect("budget via fetch_usage"); + let heads = server.join().expect("test server thread"); + assert!( + heads.iter().all(|head| !head.contains("gateway-key")), + "{heads:?}" + ); + assert!(result.usage.primary.reset_description.is_some()); +} + +#[tokio::test] +async fn without_a_token_fetch_usage_keeps_the_team_credits_endpoint() { + let (base, server) = serve_sequence(vec![( + "200 OK", + r#"{"balance":"4.50","total_used":"1.25"}"#.into(), + )]); + let client = Client::builder() + .redirect(Policy::none()) + .build() + .expect("test HTTP client"); + let unconfigured = VercelProvider::with_client(format!("{base}/v1/credits"), client) + .with_api_base_url(&base) + .with_access_token_lookup(|| None); + let context = FetchContext { + api_key: Some("gateway-key".into()), + workspace_id: Some(TEAM.into()), + ..FetchContext::default() + }; + let result = unconfigured + .fetch_usage(&context) + .await + .expect("credits fetch"); + let heads = server.join().expect("test server thread"); + assert!(heads[0].starts_with("get /v1/credits "), "{}", heads[0]); + assert!(result.usage.primary.is_informational); + assert_eq!(result.display_details()[0].title(), "Available balance"); +} diff --git a/rust/src/providers/vercel/mod.rs b/rust/src/providers/vercel/mod.rs index ba0f9fd0919..fd3099817c3 100644 --- a/rust/src/providers/vercel/mod.rs +++ b/rust/src/providers/vercel/mod.rs @@ -1,12 +1,19 @@ -//! Vercel AI Gateway credit balance provider. +//! Vercel AI Gateway 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. +//! Default (team credits): `GET https://ai-gateway.vercel.sh/v1/credits` with a +//! bearer AI Gateway 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. +//! +//! Opt-in (personal budget, upstream 0.79 #4623/#4650): when a Vercel access +//! token is configured, the card shows the token user's AI Gateway budget +//! instead. The access token is sent only to `api.vercel.com`; see `budget.rs`. + +mod budget; use async_trait::async_trait; +use chrono::Utc; use reqwest::{Client, StatusCode, redirect::Policy}; use serde::Deserialize; use std::time::Duration; @@ -18,15 +25,24 @@ use crate::core::{ use crate::providers::{BoundedBodyError, format, read_bounded_response}; const CREDITS_URL: &str = "https://ai-gateway.vercel.sh/v1/credits"; +const API_BASE_URL: &str = "https://api.vercel.com"; const CREDENTIAL_TARGET: &str = "codexbar-vercel"; const ENV_KEYS: &[&str] = &["AI_GATEWAY_API_KEY"]; +const ACCESS_TOKEN_ENV: &str = "VERCEL_ACCESS_TOKEN"; +const TEAM_ENV: &str = "VERCEL_TEAM_ID"; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const SECTION_LABEL: &str = "Team credits"; +/// Source of the optional personal-budget access token. A plain function +/// pointer so tests can pin it without reading the user's settings. +type AccessTokenLookup = fn() -> Option; + pub struct VercelProvider { client: Client, credits_url: String, + api_base_url: String, + access_token_lookup: AccessTokenLookup, } impl VercelProvider { @@ -36,16 +52,32 @@ impl VercelProvider { .timeout(REQUEST_TIMEOUT) .build() .unwrap_or_else(|_| Client::new()); - Self::with_client(CREDITS_URL, client) + let mut provider = Self::with_client(CREDITS_URL, client); + provider.access_token_lookup = configured_access_token; + provider } fn with_client(credits_url: impl Into, client: Client) -> Self { Self { client, credits_url: credits_url.into(), + api_base_url: API_BASE_URL.to_owned(), + access_token_lookup: || None, } } + #[cfg(test)] + fn with_api_base_url(mut self, api_base_url: impl Into) -> Self { + self.api_base_url = api_base_url.into().trim_end_matches('/').to_owned(); + self + } + + #[cfg(test)] + fn with_access_token_lookup(mut self, lookup: AccessTokenLookup) -> Self { + self.access_token_lookup = lookup; + self + } + async fn fetch_credits( &self, ctx: &FetchContext, @@ -87,7 +119,10 @@ impl Provider for VercelProvider { async fn fetch_usage(&self, ctx: &FetchContext) -> Result { match ctx.source_mode { - SourceMode::Auto | SourceMode::OAuth => self.fetch_credits(ctx).await, + SourceMode::Auto | SourceMode::OAuth => match (self.access_token_lookup)() { + Some(token) => self.fetch_personal_budget(ctx, &token, Utc::now()).await, + None => self.fetch_credits(ctx).await, + }, SourceMode::Web | SourceMode::Cli => { Err(ProviderError::UnsupportedSource(ctx.source_mode)) } @@ -99,6 +134,16 @@ impl Provider for VercelProvider { } } +/// The personal-budget token: the provider's stored secret (DPAPI-protected +/// settings, same slot as OpenRouter's management key), else the environment. +fn configured_access_token() -> Option { + let stored = crate::settings::Settings::load() + .management_api_token(ProviderId::Vercel) + .map(str::to_owned); + let env = std::env::var(ACCESS_TOKEN_ENV).ok(); + budget::pick_access_token(stored.as_deref(), env.as_deref()) +} + /// Wire shape at upstream v0.66.0: both amounts are decimal strings. #[derive(Debug, Deserialize)] struct CreditsResponse { From 97fb8e86503882299d1b417e0ecbdf22bdee233b Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:37:33 +0700 Subject: [PATCH 2/4] Add Vercel access token commands and Team ID validation - Store the optional Vercel personal-budget access token in the provider's management token slot, the same DPAPI-protected settings field OpenRouter uses. Add has, set and remove Tauri commands. The token is never returned to the UI. - Validate a Vercel Team ID when it is saved, using the same shape rule the budget request enforces, so an invalid ID is rejected up front. --- .../src/commands/provider_settings.rs | 28 +++++++++++++++++++ apps/desktop-tauri/src-tauri/src/main.rs | 3 ++ rust/src/providers/vercel/budget.rs | 4 ++- rust/src/providers/vercel/mod.rs | 2 ++ rust/src/settings/provider_workspace.rs | 27 ++++++++++++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) 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 f8805d94530..9ca4eb1b20a 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -222,6 +222,34 @@ pub fn remove_openrouter_management_api_key() -> Result<(), String> { settings.save().map_err(|error| error.to_string()) } +// ── Vercel personal budget access token ────────────────────────────── + +#[tauri::command] +pub fn has_vercel_access_token() -> bool { + Settings::load() + .management_api_token(ProviderId::Vercel) + .is_some() +} + +#[tauri::command] +pub fn set_vercel_access_token(access_token: String) -> Result<(), String> { + let trimmed = access_token.trim(); + if trimmed.is_empty() { + return Err("Vercel access token must not be empty".to_string()); + } + validate_single_line_secret(trimmed, "Vercel access token", MAX_API_KEY_LEN)?; + let mut settings = Settings::load(); + settings.set_management_api_token(ProviderId::Vercel, Some(trimmed.to_string())); + settings.save().map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn remove_vercel_access_token() -> Result<(), String> { + let mut settings = Settings::load(); + settings.set_management_api_token(ProviderId::Vercel, None); + settings.save().map_err(|error| error.to_string()) +} + // ── Azure OpenAI API version ───────────────────────────────────────── fn azure_openai_provider(provider_id: &str) -> Result { diff --git a/apps/desktop-tauri/src-tauri/src/main.rs b/apps/desktop-tauri/src-tauri/src/main.rs index f978af0386f..ca66ff3470a 100644 --- a/apps/desktop-tauri/src-tauri/src/main.rs +++ b/apps/desktop-tauri/src-tauri/src/main.rs @@ -310,6 +310,9 @@ fn main() { commands::has_openrouter_management_api_key, commands::set_openrouter_management_api_key, commands::remove_openrouter_management_api_key, + commands::has_vercel_access_token, + commands::set_vercel_access_token, + commands::remove_vercel_access_token, commands::get_provider_azure_api_version, commands::set_provider_azure_api_version, commands::get_provider_cookie_source_options, diff --git a/rust/src/providers/vercel/budget.rs b/rust/src/providers/vercel/budget.rs index 147e86f18ff..6fae74d9c47 100644 --- a/rust/src/providers/vercel/budget.rs +++ b/rust/src/providers/vercel/budget.rs @@ -65,7 +65,9 @@ pub(super) fn resolve_team( Ok(team.to_string()) } -fn is_team_id(value: &str) -> bool { +/// Shape accepted for a Vercel team ID or slug. Also used when saving the +/// Team ID setting, so an invalid value is rejected before any request. +pub(crate) fn is_team_id(value: &str) -> bool { value.len() <= 128 && value .bytes() diff --git a/rust/src/providers/vercel/mod.rs b/rust/src/providers/vercel/mod.rs index fd3099817c3..52efeac1806 100644 --- a/rust/src/providers/vercel/mod.rs +++ b/rust/src/providers/vercel/mod.rs @@ -12,6 +12,8 @@ mod budget; +pub(crate) use budget::is_team_id; + use async_trait::async_trait; use chrono::Utc; use reqwest::{Client, StatusCode, redirect::Policy}; diff --git a/rust/src/settings/provider_workspace.rs b/rust/src/settings/provider_workspace.rs index dd906d93cb0..e742cc326ff 100644 --- a/rust/src/settings/provider_workspace.rs +++ b/rust/src/settings/provider_workspace.rs @@ -76,6 +76,11 @@ pub fn validate_provider_workspace_value( "FLORA workspace ID", crate::providers::flora::is_workspace_id, ), + ProviderId::Vercel => validate_id( + trimmed, + "Vercel Team ID", + crate::providers::vercel::is_team_id, + ), ProviderId::Exa => { if trimmed.is_empty() || trimmed == "." || trimmed == ".." { return Err( @@ -259,6 +264,28 @@ mod tests { assert!(validate_provider_workspace_value(ProviderId::GitKraken, "org\n123").is_err()); } + #[test] + fn validates_vercel_team_ids() { + assert_eq!( + validate_provider_workspace_value(ProviderId::Vercel, " team_abc123 ").unwrap(), + "team_abc123" + ); + assert_eq!( + validate_provider_workspace_value(ProviderId::Vercel, "my-team").unwrap(), + "my-team" + ); + assert_eq!( + validate_provider_workspace_value(ProviderId::Vercel, " ").unwrap(), + "" + ); + for invalid in ["team id", "team/../x", "team?x=1", "team\n1"] { + assert!( + validate_provider_workspace_value(ProviderId::Vercel, invalid).is_err(), + "{invalid:?} must be rejected" + ); + } + } + #[test] fn validates_flora_workspace_ids() { assert_eq!( From 98e56304b40d9df675223f9288f81c9c18e0f9d5 Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:37:40 +0700 Subject: [PATCH 3/4] Add Vercel personal budget settings fields Settings > Providers > Vercel gains an access-token field and a Team ID field. Both are optional: without a token the card keeps showing team credits. Strings are added to every locale, and new proof scenarios cover the budget card and the settings pane with synthetic data. --- .../scripts/proof-scenarios.json | 20 ++++ .../desktop-tauri/src/i18n/keyGroups/shell.ts | 7 ++ apps/desktop-tauri/src/lib/tauri.ts | 12 ++ .../src/proof/fixtures/common.ts | 1 + .../src/proof/fixtures/scenarios.ts | 62 ++++++++++ apps/desktop-tauri/src/proof/router.ts | 3 + .../sections/CredentialsDispatcher.tsx | 8 ++ .../sections/credentials/OpenAiExtras.tsx | 8 ++ .../credentials/VercelAccessTokenCreds.tsx | 109 ++++++++++++++++++ rust/src/locale.rs | 7 ++ rust/src/locale/en-US.ftl | 7 ++ rust/src/locale/es-MX.ftl | 7 ++ rust/src/locale/ja-JP.ftl | 7 ++ rust/src/locale/ko-KR.ftl | 7 ++ rust/src/locale/pt-BR.ftl | 7 ++ rust/src/locale/ru-RU.ftl | 7 ++ rust/src/locale/tr-TR.ftl | 7 ++ rust/src/locale/uk-UA.ftl | 7 ++ rust/src/locale/zh-CN.ftl | 7 ++ rust/src/locale/zh-TW.ftl | 7 ++ 20 files changed, 307 insertions(+) create mode 100644 apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index f5c42d77ab4..e1d05658fc0 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -327,6 +327,26 @@ { "screenshot": "settings-claude-token-account-config-dir-cleared" } ] }, + { + "name": "flyout-vercel-budget", + "query": "window=flyout&fixture=vercel&theme=dark", + "size": [310, 1032], + "steps": [ + { "expectText": ["Vercel", "Personal budget", "Spend", "Limit", "Remaining", "$58.00"] }, + { "expectNoText": "Available balance" }, + { "screenshot": "flyout-vercel-budget", "clip": "panel" } + ] + }, + { + "name": "settings-vercel-budget", + "query": "window=settings&tab=providers:vercel&fixture=vercel&theme=dark", + "size": [880, 1400], + "steps": [ + { "expectText": ["Vercel personal budget", "Access token", "Vercel team", "Team ID"] }, + { "expectNoText": "Remove" }, + { "screenshot": "settings-vercel-budget" } + ] + }, { "name": "settings-gemini-usage-source", "query": "window=settings&tab=providers:gemini&fixture=default&theme=dark", diff --git a/apps/desktop-tauri/src/i18n/keyGroups/shell.ts b/apps/desktop-tauri/src/i18n/keyGroups/shell.ts index d605d72edf1..6884c326407 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/shell.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/shell.ts @@ -43,6 +43,13 @@ export const SHELL_LOCALE_KEYS = [ "OpenRouterManagementKeyLabel", "OpenRouterManagementKeyHelp", "OpenRouterManagementKeyConfigured", + "VercelAccessTokenTitle", + "VercelAccessTokenLabel", + "VercelAccessTokenHelp", + "VercelAccessTokenConfigured", + "VercelTeamTitle", + "VercelTeamIdLabel", + "VercelTeamIdHelp", "CredsOpenAiHistoryHelp", // Phase 6e — Token accounts (review) "TokenAccountActive", diff --git a/apps/desktop-tauri/src/lib/tauri.ts b/apps/desktop-tauri/src/lib/tauri.ts index 99880aa592a..e412e2f87c9 100644 --- a/apps/desktop-tauri/src/lib/tauri.ts +++ b/apps/desktop-tauri/src/lib/tauri.ts @@ -280,6 +280,18 @@ export function removeOpenRouterManagementApiKey(): Promise { return invoke("remove_openrouter_management_api_key"); } +export function hasVercelAccessToken(): Promise { + return invoke("has_vercel_access_token"); +} + +export function setVercelAccessToken(accessToken: string): Promise { + return invoke("set_vercel_access_token", { accessToken }); +} + +export function removeVercelAccessToken(): Promise { + return invoke("remove_vercel_access_token"); +} + export function getManualCookies(): Promise { return invoke("get_manual_cookies"); } diff --git a/apps/desktop-tauri/src/proof/fixtures/common.ts b/apps/desktop-tauri/src/proof/fixtures/common.ts index 213e438435e..7b8a34abe6f 100644 --- a/apps/desktop-tauri/src/proof/fixtures/common.ts +++ b/apps/desktop-tauri/src/proof/fixtures/common.ts @@ -97,6 +97,7 @@ export const PROOF_PROVIDER_CATALOG: ProviderCatalogEntry[] = [ { id: "zai", displayName: "z.ai", cookieDomain: null }, { id: "mistral", displayName: "Mistral", cookieDomain: null }, { id: "openaiapi", displayName: "OpenAI API", cookieDomain: null }, + { id: "vercel", displayName: "Vercel", cookieDomain: null }, ]; export function catalogOrder(): string[] { diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 843bdc39b53..b20b51428f9 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -40,6 +40,7 @@ export const PROOF_SCENARIO_NAMES = [ "kpi-long-titles", "claudeplatform", "cookie-no-browsers", + "vercel", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -214,6 +215,53 @@ function claudePlatformSnapshot(now: Date): ProviderUsageSnapshot { ); } +/** + * Opt-in personal AI Gateway budget (synthetic): spend, limit and remaining + * rows under a "Personal budget" section, with the team as the organization. + */ +function vercelSnapshot(now: Date): ProviderUsageSnapshot { + return providerSnapshot( + { + providerId: "vercel", + displayName: "Vercel", + primary: rateWindow(now, 42, MONTH_MINUTES, 17 * DAY_MINUTES), + primaryLabel: "Personal budget", + updatedAt: isoBefore(now, 2), + }, + { + secondary: null, + accountOrganization: "Example Team", + sourceLabel: "api", + displayDetails: [ + { + id: "vercel-budget-spend", + sectionTitle: "Personal budget", + title: "Spend", + value: "$42.00", + secondaryValue: null, + progress: null, + }, + { + id: "vercel-budget-limit", + sectionTitle: "Personal budget", + title: "Limit", + value: "$100.00", + secondaryValue: null, + progress: null, + }, + { + id: "vercel-budget-remaining", + sectionTitle: "Personal budget", + title: "Remaining", + value: "$58.00", + secondaryValue: null, + progress: null, + }, + ], + }, + ); +} + function codexAccount( now: Date, id: string, @@ -777,6 +825,19 @@ function claudePlatformScenario(now: Date): ProofScenario { }; } +/** Synthetic personal-budget card and settings pane for the opt-in Vercel budget. */ +function vercelScenario(now: Date): ProofScenario { + return { + name: "vercel", + settings: baseSettings({ enabledProviders: ["vercel"] }), + catalog: PROOF_PROVIDER_CATALOG, + providers: [vercelSnapshot(now)], + codexAccounts: emptyCodexAccounts(), + claudeAccounts: [], + localSpend: [], + }; +} + /** * Default data on a machine with no importable browser, so the cookie section * shows its empty-state hint beside the paste box. @@ -805,6 +866,7 @@ const BUILDERS: Record ProofScenario> = { "kpi-long-titles": kpiLongTitlesScenario, claudeplatform: claudePlatformScenario, "cookie-no-browsers": cookieNoBrowsersScenario, + vercel: vercelScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts index a17b84ba2ec..f582457e8e3 100644 --- a/apps/desktop-tauri/src/proof/router.ts +++ b/apps/desktop-tauri/src/proof/router.ts @@ -79,6 +79,7 @@ export const INERT_ACTIONS = [ "remove_manual_cookie", "remove_openrouter_management_api_key", "remove_token_account", + "remove_vercel_access_token", "revoke_provider_credentials", "save_hooks_config", "set_active_token_account", @@ -93,6 +94,7 @@ export const INERT_ACTIONS = [ "set_provider_region", "set_provider_usage_source", "set_provider_workspace_id", + "set_vercel_access_token", "trigger_provider_login", "unregister_global_shortcut", "write_usage_spend_export", @@ -312,6 +314,7 @@ export function createProofRouter( get_api_keys: () => [], get_api_key_providers: () => [], has_openrouter_management_api_key: () => false, + has_vercel_access_token: () => false, get_manual_cookies: () => [], list_detected_browsers: (args) => scenario.detectedBrowsers ?? [ diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx index 8a6cd322daa..e3c2b531feb 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -5,6 +5,7 @@ import { JetBrainsCreds } from "./credentials/JetBrainsCreds"; import { KiroCreds } from "./credentials/KiroCreds"; import { hasWorkspaceExtra, OpenAiExtras } from "./credentials/OpenAiExtras"; import { OpenRouterManagementCreds } from "./credentials/OpenRouterManagementCreds"; +import { VercelAccessTokenCreds } from "./credentials/VercelAccessTokenCreds"; interface Props { providerId: string; @@ -28,6 +29,13 @@ export function CredentialsDispatcher({ providerId, t }: Props) { return ; case "openrouter": return ; + case "vercel": + return ( + <> + + + + ); default: return hasWorkspaceExtra(providerId) ? ( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx index 4161384a517..47a8bb6ace3 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.tsx @@ -117,6 +117,7 @@ const WORKSPACE_EXTRA_IDS: Record = { exa: true, cosmic: true, flora: true, + vercel: true, }; function extraConfig(providerId: string, t: Props["t"]) { @@ -212,6 +213,13 @@ function extraConfig(providerId: string, t: Props["t"]) { placeholder: t("CosmicProjectIdPlaceholder"), help: t("CosmicProjectIdHelp"), }; + case "vercel": + return { + title: t("VercelTeamTitle"), + label: t("VercelTeamIdLabel"), + placeholder: "team_...", + help: t("VercelTeamIdHelp"), + }; case "flora": return { title: "FLORA workspace", diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx new file mode 100644 index 00000000000..918acba76c5 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from "react"; +import type { LocaleKey } from "../../../../../i18n/keys"; +import { + hasVercelAccessToken, + removeVercelAccessToken, + setVercelAccessToken, +} from "../../../../../lib/tauri"; + +interface Props { + t: (key: LocaleKey) => string; +} + +/** + * Optional Vercel access token for the personal AI Gateway budget. Stored + * through the provider's secret slot; the value is never read back to the UI. + */ +export function VercelAccessTokenCreds({ t }: Props) { + const [configured, setConfigured] = useState(false); + const [value, setValue] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void hasVercelAccessToken() + .then((present) => { + if (!cancelled) setConfigured(present); + }) + .catch((cause: unknown) => { + if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause)); + }); + return () => { + cancelled = true; + }; + }, []); + + const save = async () => { + const next = value.trim(); + if (!next) return; + setBusy(true); + setError(null); + try { + await setVercelAccessToken(next); + setConfigured(true); + setValue(""); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + setBusy(true); + setError(null); + try { + await removeVercelAccessToken(); + setConfigured(false); + setValue(""); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{t("VercelAccessTokenTitle")}

+ +
{t("VercelAccessTokenHelp")}
+
+ + {configured && ( + + )} +
+ {error &&
{error}
} +
+ ); +} diff --git a/rust/src/locale.rs b/rust/src/locale.rs index f5c8bb4f166..9caf6e86a8a 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1463,6 +1463,13 @@ locale_keys! { OpenRouterManagementKeyLabel, OpenRouterManagementKeyHelp, OpenRouterManagementKeyConfigured, + VercelAccessTokenTitle, + VercelAccessTokenLabel, + VercelAccessTokenHelp, + VercelAccessTokenConfigured, + VercelTeamTitle, + VercelTeamIdLabel, + VercelTeamIdHelp, CredsOpenAiHistoryHelp, // Tauri desktop shell — Token accounts (Phase 6e, review) diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 9d950433687..9bcd61b386a 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -1153,6 +1153,13 @@ OpenRouterManagementKeyTitle = OpenRouter spend history OpenRouterManagementKeyLabel = Management API key OpenRouterManagementKeyHelp = Optional additional key for account Activity. Only needed to use a separate Management API key from the one in the required API key field above. You can also set OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Management key configured +VercelAccessTokenTitle = Vercel personal budget +VercelAccessTokenLabel = Access token +VercelAccessTokenHelp = Optional. With a token, the card shows your personal AI Gateway budget for the Team ID below instead of team credits. Stored securely. You can also set VERCEL_ACCESS_TOKEN. +VercelAccessTokenConfigured = Token saved +VercelTeamTitle = Vercel team +VercelTeamIdLabel = Team ID +VercelTeamIdHelp = Required for a personal budget. The ID or slug of the team that owns the AI Gateway. You can also set VERCEL_TEAM_ID. ClaudeAccountsTitle = Claude Code accounts ClaudeAccountsHint = Enable reading Claude Code credentials to check usage for every saved account. Close Claude Code before switching, then reopen it. Claude Desktop uses a separate login. ClaudeAccountsEmpty = No Claude Code accounts found. diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 6a0f510e7fc..2af02490fdd 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -1150,6 +1150,13 @@ OpenRouterManagementKeyTitle = Historial de gasto de OpenRouter OpenRouterManagementKeyLabel = Clave de API de administración OpenRouterManagementKeyHelp = Clave adicional opcional para la actividad de la cuenta. Solo es necesaria para usar una clave de API de administración distinta de la del campo obligatorio de clave API de arriba. También puedes definir OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Clave de administración configurada +VercelAccessTokenTitle = Presupuesto personal de Vercel +VercelAccessTokenLabel = Token de acceso +VercelAccessTokenHelp = Opcional. Con un token, la tarjeta muestra tu presupuesto personal de AI Gateway para el ID de equipo indicado abajo, en lugar de los créditos del equipo. Se guarda de forma segura. También puedes definir VERCEL_ACCESS_TOKEN. +VercelAccessTokenConfigured = Token guardado +VercelTeamTitle = Equipo de Vercel +VercelTeamIdLabel = ID de equipo +VercelTeamIdHelp = Obligatorio para el presupuesto personal. El ID o slug del equipo que es dueño de AI Gateway. También puedes definir VERCEL_TEAM_ID. ClaudeAccountsTitle = Cuentas de Claude Code ClaudeAccountsHint = Activa la lectura de credenciales de Claude Code para consultar el uso de cada cuenta guardada. Cierra Claude Code antes de cambiar y vuelve a abrirlo. Claude Desktop usa un inicio de sesión aparte. ClaudeAccountsEmpty = No se encontraron cuentas de Claude Code. diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index f4e92962b86..7f99e78962d 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -1151,6 +1151,13 @@ OpenRouterManagementKeyTitle = OpenRouter のコスト履歴 OpenRouterManagementKeyLabel = Management API キー OpenRouterManagementKeyHelp = アカウントのアクティビティ用の追加キー(任意)。上の必須 API キー欄とは別の Management API キーを使う場合のみ必要です。OPENROUTER_MANAGEMENT_API_KEY でも設定できます。 OpenRouterManagementKeyConfigured = Management キー設定済み +VercelAccessTokenTitle = Vercel の個人予算 +VercelAccessTokenLabel = アクセストークン +VercelAccessTokenHelp = 任意。トークンを設定すると、カードにはチームのクレジットではなく、下の Team ID のチームにおける個人の AI Gateway 予算が表示されます。安全に保存されます。VERCEL_ACCESS_TOKEN でも設定できます。 +VercelAccessTokenConfigured = トークン保存済み +VercelTeamTitle = Vercel チーム +VercelTeamIdLabel = Team ID +VercelTeamIdHelp = 個人予算には必須です。AI Gateway を所有するチームの ID または slug。VERCEL_TEAM_ID でも設定できます。 ClaudeAccountsTitle = Claude Code アカウント ClaudeAccountsHint = Claude Code の認証情報の読み取りを有効にすると、保存済みのすべてのアカウントの使用量を確認できます。切り替える前に Claude Code を閉じ、その後再度開いてください。Claude Desktop は別のログインを使用します。 ClaudeAccountsEmpty = Claude Code アカウントが見つかりません。 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index ebdfe18a154..415a78e3956 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -1150,6 +1150,13 @@ OpenRouterManagementKeyTitle = OpenRouter 비용 기록 OpenRouterManagementKeyLabel = Management API 키 OpenRouterManagementKeyHelp = 계정 활동용 추가 키(선택 사항)입니다. 위의 필수 API 키 항목과 다른 Management API 키를 사용할 때만 필요합니다. OPENROUTER_MANAGEMENT_API_KEY로도 설정할 수 있습니다. OpenRouterManagementKeyConfigured = Management 키 설정됨 +VercelAccessTokenTitle = Vercel 개인 예산 +VercelAccessTokenLabel = 액세스 토큰 +VercelAccessTokenHelp = 선택 사항. 토큰을 설정하면 카드에 팀 크레딧 대신 아래 Team ID의 개인 AI Gateway 예산이 표시됩니다. 안전하게 저장됩니다. VERCEL_ACCESS_TOKEN으로도 설정할 수 있습니다. +VercelAccessTokenConfigured = 토큰 저장됨 +VercelTeamTitle = Vercel 팀 +VercelTeamIdLabel = Team ID +VercelTeamIdHelp = 개인 예산에 필요합니다. AI Gateway를 소유한 팀의 ID 또는 slug입니다. VERCEL_TEAM_ID로도 설정할 수 있습니다. ClaudeAccountsTitle = Claude Code 계정 ClaudeAccountsHint = 저장된 모든 계정의 사용량을 확인하려면 Claude Code 자격 증명 읽기를 활성화하세요. 전환하기 전에 Claude Code를 닫고 다시 여세요. Claude Desktop은 별도 로그인을 사용합니다. ClaudeAccountsEmpty = Claude Code 계정이 없습니다. diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index ba7a38e8828..f8bf33ab546 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -1071,6 +1071,13 @@ OpenRouterManagementKeyTitle = Histórico de gastos do OpenRouter OpenRouterManagementKeyLabel = Chave da API de gerenciamento OpenRouterManagementKeyHelp = Opcional. Usada apenas para obter o histórico exato de gastos dos últimos 30 dias em openrouter.ai. A chave principal da API do OpenRouter permanece separada. Você também pode definir OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Chave de gerenciamento configurada +VercelAccessTokenTitle = Orçamento pessoal da Vercel +VercelAccessTokenLabel = Token de acesso +VercelAccessTokenHelp = Opcional. Com um token, o cartão mostra seu orçamento pessoal do AI Gateway para o Team ID abaixo, em vez dos créditos da equipe. Armazenado com segurança. Você também pode definir VERCEL_ACCESS_TOKEN. +VercelAccessTokenConfigured = Token salvo +VercelTeamTitle = Equipe da Vercel +VercelTeamIdLabel = ID da equipe +VercelTeamIdHelp = Obrigatório para o orçamento pessoal. O ID ou slug da equipe que possui o AI Gateway. Você também pode definir VERCEL_TEAM_ID. ClaudeAccountsTitle = Contas do Claude Code ClaudeAccountsHint = Feche o Claude Code antes de mudar e reabra-o. Claude Desktop usa um login separado. ClaudeAccountsEmpty = Nenhuma conta do Claude Code encontrada. diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 9fc107ea8a0..b765c9e4e34 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -1151,6 +1151,13 @@ OpenRouterManagementKeyTitle = История расходов OpenRouter OpenRouterManagementKeyLabel = Ключ Management API OpenRouterManagementKeyHelp = Необязательный дополнительный ключ для активности аккаунта. Нужен, только если ключ Management API отличается от указанного выше в обязательном поле API-ключа. Можно также задать OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Ключ Management API задан +VercelAccessTokenTitle = Личный бюджет Vercel +VercelAccessTokenLabel = Токен доступа +VercelAccessTokenHelp = Необязательно. С токеном карточка показывает ваш личный бюджет AI Gateway для Team ID ниже вместо кредитов команды. Хранится безопасно. Можно также задать VERCEL_ACCESS_TOKEN. +VercelAccessTokenConfigured = Токен сохранён +VercelTeamTitle = Команда Vercel +VercelTeamIdLabel = ID команды +VercelTeamIdHelp = Обязательно для личного бюджета. ID или slug команды, которой принадлежит AI Gateway. Можно также задать VERCEL_TEAM_ID. ClaudeAccountsTitle = Аккаунты Claude Code ClaudeAccountsHint = Разрешите чтение учетных данных Claude Code, чтобы проверять использование по каждому сохраненному аккаунту. Перед переключением закройте Claude Code, затем откройте снова. У Claude Desktop отдельный вход. ClaudeAccountsEmpty = Аккаунты Claude Code не найдены. diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 581f8041041..b8e3b6676a9 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -1101,6 +1101,13 @@ OpenRouterManagementKeyTitle = OpenRouter harcama geçmişi OpenRouterManagementKeyLabel = Management API anahtarı OpenRouterManagementKeyHelp = Hesap Etkinliği için isteğe bağlı ek anahtar. Yalnızca yukarıdaki zorunlu API anahtarı alanındakinden ayrı bir Management API anahtarı kullanmak için gerekir. OPENROUTER_MANAGEMENT_API_KEY de ayarlayabilirsiniz. OpenRouterManagementKeyConfigured = Management anahtarı yapılandırıldı +VercelAccessTokenTitle = Vercel kişisel bütçesi +VercelAccessTokenLabel = Erişim belirteci +VercelAccessTokenHelp = İsteğe bağlı. Bir belirteçle kart, takım kredileri yerine aşağıdaki Team ID için kişisel AI Gateway bütçenizi gösterir. Güvenli şekilde saklanır. VERCEL_ACCESS_TOKEN ortam değişkenini de ayarlayabilirsiniz. +VercelAccessTokenConfigured = Belirteç kaydedildi +VercelTeamTitle = Vercel takımı +VercelTeamIdLabel = Takım kimliği +VercelTeamIdHelp = Kişisel bütçe için gerekli. AI Gateway'e sahip takımın kimliği veya slug'ı. VERCEL_TEAM_ID ortam değişkenini de ayarlayabilirsiniz. ClaudeAccountsTitle = Claude Code hesapları ClaudeAccountsHint = Kayıtlı her hesabın kullanımını denetlemek için Claude Code kimlik bilgilerini okumayı etkinleştirin. Değiştirmeden önce Claude Code'u kapatın, sonra yeniden açın. Claude Desktop ayrı bir oturum kullanır. ClaudeAccountsEmpty = Claude Code hesabı bulunamadı. diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 667af4ab40b..178bb3c96f2 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -1149,6 +1149,13 @@ OpenRouterManagementKeyTitle = Історія витрат OpenRouter OpenRouterManagementKeyLabel = Ключ Management API OpenRouterManagementKeyHelp = Необов’язковий додатковий ключ для активності облікового запису. Потрібен, лише якщо ключ Management API відрізняється від указаного вище в обов’язковому полі API-ключа. Можна також задати OPENROUTER_MANAGEMENT_API_KEY. OpenRouterManagementKeyConfigured = Ключ Management API задано +VercelAccessTokenTitle = Особистий бюджет Vercel +VercelAccessTokenLabel = Токен доступу +VercelAccessTokenHelp = Необов’язково. З токеном картка показує ваш особистий бюджет AI Gateway для Team ID нижче замість кредитів команди. Зберігається безпечно. Можна також задати VERCEL_ACCESS_TOKEN. +VercelAccessTokenConfigured = Токен збережено +VercelTeamTitle = Команда Vercel +VercelTeamIdLabel = ID команди +VercelTeamIdHelp = Потрібно для особистого бюджету. ID або slug команди, якій належить AI Gateway. Можна також задати VERCEL_TEAM_ID. ClaudeAccountsTitle = Облікові записи Claude Code ClaudeAccountsHint = Дозвольте читати облікові дані Claude Code, щоб перевіряти використання для кожного збереженого облікового запису. Перед перемиканням закрийте Claude Code, потім відкрийте знову. Claude Desktop має окремий вхід. ClaudeAccountsEmpty = Облікових записів Claude Code не знайдено. diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 4a8fb77b77b..ae8c25dd253 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -1151,6 +1151,13 @@ OpenRouterManagementKeyTitle = OpenRouter 费用历史 OpenRouterManagementKeyLabel = Management API 密钥 OpenRouterManagementKeyHelp = 用于账号活动的可选附加密钥。仅当要使用与上方必填 API 密钥不同的 Management API 密钥时才需要。也可设置 OPENROUTER_MANAGEMENT_API_KEY。 OpenRouterManagementKeyConfigured = Management 密钥已配置 +VercelAccessTokenTitle = Vercel 个人预算 +VercelAccessTokenLabel = 访问令牌 +VercelAccessTokenHelp = 可选。设置令牌后,卡片将显示下方 Team ID 中你的个人 AI Gateway 预算,而不是团队额度。令牌安全存储。也可以设置环境变量 VERCEL_ACCESS_TOKEN。 +VercelAccessTokenConfigured = 令牌已保存 +VercelTeamTitle = Vercel 团队 +VercelTeamIdLabel = 团队 ID +VercelTeamIdHelp = 个人预算必填。拥有 AI Gateway 的团队 ID 或 slug。也可以设置环境变量 VERCEL_TEAM_ID。 ClaudeAccountsTitle = Claude Code 账号 ClaudeAccountsHint = 启用读取 Claude Code 凭据后,可查看每个已保存账号的用量。切换前请关闭 Claude Code,切换后再重新打开。Claude Desktop 使用独立登录。 ClaudeAccountsEmpty = 未找到 Claude Code 账号。 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 8eacb1e4bd1..7815b5fae80 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -1151,6 +1151,13 @@ OpenRouterManagementKeyTitle = OpenRouter 費用歷史 OpenRouterManagementKeyLabel = Management API 金鑰 OpenRouterManagementKeyHelp = 帳號活動用的選用額外金鑰。僅在要使用與上方必填 API 金鑰欄位不同的 Management API 金鑰時才需要。您也可以設定 OPENROUTER_MANAGEMENT_API_KEY。 OpenRouterManagementKeyConfigured = 已設定 Management 金鑰 +VercelAccessTokenTitle = Vercel 個人預算 +VercelAccessTokenLabel = 存取權杖 +VercelAccessTokenHelp = 選填。設定權杖後,卡片會顯示下方 Team ID 中你的個人 AI Gateway 預算,而非團隊額度。權杖會安全儲存。也可以設定環境變數 VERCEL_ACCESS_TOKEN。 +VercelAccessTokenConfigured = 權杖已儲存 +VercelTeamTitle = Vercel 團隊 +VercelTeamIdLabel = 團隊 ID +VercelTeamIdHelp = 個人預算必填。擁有 AI Gateway 的團隊 ID 或 slug。也可以設定環境變數 VERCEL_TEAM_ID。 ClaudeAccountsTitle = Claude Code 帳號 ClaudeAccountsHint = 啟用讀取 Claude Code 憑證,即可查看每個已儲存帳號的用量。切換前請先關閉 Claude Code,之後再重新開啟。Claude Desktop 使用獨立的登入。 ClaudeAccountsEmpty = 找不到 Claude Code 帳號。 From a4a7970fb5e087463297affbac0446b95d6fe46f Mon Sep 17 00:00:00 2001 From: Factory Agent Date: Wed, 21 Oct 2026 12:38:40 +0700 Subject: [PATCH 4/4] Document the opt-in Vercel personal budget Describe the Vercel access token and Team ID setup, the budget requests and matching rules, UTC reset boundaries, and the host each secret is sent to. Update the README provider row. --- README.md | 2 +- docs/PROVIDERS.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a2d2413c8f2..b54847e5e1a 100755 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ For Claude, browser cookies (the `sessionKey`) are preferred, because they match | 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 | +| Vercel AI Gateway | API Key (+ optional access token) | Team credit balance, lifetime spend; opt-in personal budget | | Augment | Cookies | Credits | | OpenCode | Local Config | Usage | | Kimi | Cookies | 5h Rate, Weekly | diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 829d1424e39..e57174421dd 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -824,6 +824,46 @@ Origin: https://app.cosmicjs.com - 401, 403, and 429 report their status; response bodies are never echoed in errors. +### Vercel AI Gateway + +By default the card shows the team's AI Gateway balance and lifetime spend. It +reads `GET https://ai-gateway.vercel.sh/v1/credits` with `AI_GATEWAY_API_KEY` +(or the key saved in Settings) and reports no quota percentage. + +The personal budget is opt-in and off until you add a Vercel access token. In +Settings → Providers → Vercel, enter the **Access token** and the **Team ID**, or +set `VERCEL_ACCESS_TOKEN` and `VERCEL_TEAM_ID`. The token is stored with the +provider's secret settings, like the OpenRouter management key, is redacted from +config dumps and is never logged. The Team ID is stored in the provider's +workspace slot and is validated before any request. Clearing the token returns +the card to team credits. + +With a token, the provider makes these read-only requests to `api.vercel.com`: + +```text +GET https://api.vercel.com/v2/user +GET https://api.vercel.com/ai-gateway/budgets/list?teamId=&scopeType=user&limit=200&cursor= +Authorization: Bearer +``` + +- The budget list is followed by its cursor for at most 20 pages. A repeated + cursor or a page limit reached before the list ends fails the refresh. +- A budget applies to the token's user when its scope ID is the user's ID or + the `usr_`-prefixed form. A custom budget wins over an inherited default. Two + budgets that both apply are ambiguous and fail the refresh. +- With no applicable budget, the card shows "No personal budget configured", + not zero spend. +- The primary window shows spend as a share of the limit. Rows under + **Personal budget** show Spend, Limit and Remaining (never below zero). +- Daily, weekly and monthly budgets reset at UTC boundaries: midnight UTC each + day, Monday 00:00 UTC each week, and the first day of the month at 00:00 UTC. + A `none` budget is cumulative and has no reset. + +The access token is sent only to `api.vercel.com`. The AI Gateway key is sent +only to `ai-gateway.vercel.sh`. 401 asks you to check the token; 403, 429 and 5xx +report their status. A token without a Team ID fails with a message that asks +for one. Response bodies are never echoed in errors. + ## Window lengths that match the Mac These providers set window lengths so their rows get the same pace as in the Mac app, and the tray tint can score them. Where the stored length differs from the Mac's, the row says so: