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 9ca4eb1b20a..3834413b821 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs @@ -443,6 +443,7 @@ fn workspace_provider(provider_id: &str) -> Option { "exa" => ProviderId::Exa, "cosmic" => ProviderId::Cosmic, "flora" => ProviderId::Flora, + "vercel" => ProviderId::Vercel, _ => return None, }) } @@ -590,6 +591,22 @@ mod tests { assert_eq!(workspace_provider("flora"), Some(ProviderId::Flora)); } + #[test] + fn maps_vercel_team_provider_and_validates_its_team_id() { + let id = workspace_provider("vercel").expect("vercel exposes a team id"); + assert_eq!(id, ProviderId::Vercel); + assert_eq!( + codexbar::settings::validate_provider_workspace_value(id, " team_abc123 ").unwrap(), + "team_abc123" + ); + assert_eq!( + codexbar::settings::validate_provider_workspace_value(id, "my-team").unwrap(), + "my-team" + ); + assert!(codexbar::settings::validate_provider_workspace_value(id, "bad team").is_err()); + assert!(codexbar::settings::validate_provider_workspace_value(id, "../team").is_err()); + } + #[test] fn litellm_endpoint_change_requires_reentering_saved_key() { assert!( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.test.tsx index 2a78cb1de43..8c18af416a5 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenAiExtras.test.tsx @@ -65,6 +65,23 @@ describe("OpenAiExtras", () => { ); }); + it("renders the Team ID field for Vercel and saves it through the workspace command", async () => { + renderExtras("vercel"); + expect(await screen.findByText("VercelTeamTitle")).toBeInTheDocument(); + expect(screen.getByText("VercelTeamIdLabel")).toBeInTheDocument(); + const input = (await screen.findByRole("textbox")) as HTMLInputElement; + expect(input.placeholder).toBe("team_..."); + + await act(async () => { + fireEvent.change(input, { target: { value: "team_abc123" } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Save" })); + }); + + expect(setProviderWorkspaceId).toHaveBeenCalledWith("vercel", "team_abc123"); + }); + it("renders help text for Cosmic Project ID", async () => { renderExtras("cosmic"); expect( diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e2ca8ae54fe..30fcc8fc3b1 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -862,9 +862,20 @@ Authorization: Bearer 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. +only to `ai-gateway.vercel.sh`. + +Status handling differs by request: + +- `/v2/user`: 401 means Vercel did not accept the token, so the card asks you + to check or replace it. 403 is returned both for a bogus token and for a + scoped token without the `user` permission, so the card asks you to check the + token's scope or replace it. +- `/ai-gateway/budgets/list`: 401 asks you to check the token. 403 means the + token's user needs a non-Contributor role on the team to read budgets. +- 429 and 5xx report their status on either request. + +A token without a Team ID fails with a message that asks for one. Response +bodies are never echoed in errors. ## Grok diff --git a/rust/src/providers/vercel/budget.rs b/rust/src/providers/vercel/budget.rs index 6fae74d9c47..095f3e296e7 100644 --- a/rust/src/providers/vercel/budget.rs +++ b/rust/src/providers/vercel/budget.rs @@ -79,13 +79,29 @@ fn parse_failure() -> ProviderError { ProviderError::Parse(UNRECOGNIZED.into()) } +/// The two requests on the personal budget path. A 403 means different things +/// on each: `/v2/user` answers 403 for a token it does not accept (a bogus +/// token was observed to get 403 there), while the budget list answers 403 when +/// the token's user lacks a role that can read budgets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Endpoint { + CurrentUser, + Budgets, +} + +const REJECTED_TOKEN: &str = "Vercel rejected the access token. Check or replace it in Settings."; +// Vercel answers 403 both for a bogus token and for a scoped token without the +// `user` permission, so the message has to cover both. +const USER_ACCESS_DENIED: &str = "Vercel rejected the access token, or the token lacks permission to read the current user. Check its scope or replace it in Settings."; + /// Status mapping keeps the code but never the response body. -fn status_error(status: StatusCode) -> ProviderError { +fn status_error(status: StatusCode, endpoint: Endpoint) -> 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::UNAUTHORIZED => ProviderError::Other(REJECTED_TOKEN.into()), + StatusCode::FORBIDDEN if endpoint == Endpoint::CurrentUser => { + ProviderError::Other(USER_ACCESS_DENIED.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." )), @@ -283,7 +299,11 @@ impl VercelProvider { 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 user = parse_user( + &self + .get_budget_json(&user_url, token, &[], Endpoint::CurrentUser) + .await?, + )?; let budgets_url = format!("{}/ai-gateway/budgets/list", self.api_base_url); let mut matches: Vec = Vec::new(); @@ -299,7 +319,9 @@ impl VercelProvider { if let Some(current) = cursor.as_deref() { query.push(("cursor", current)); } - let body = self.get_budget_json(&budgets_url, token, &query).await?; + let body = self + .get_budget_json(&budgets_url, token, &query, Endpoint::Budgets) + .await?; let rows = body .get("budgets") .and_then(Value::as_array) @@ -337,6 +359,7 @@ impl VercelProvider { url: &str, token: &str, query: &[(&str, &str)], + endpoint: Endpoint, ) -> Result { let response = self .client @@ -348,7 +371,7 @@ impl VercelProvider { .await?; let status = response.status(); if status != StatusCode::OK { - return Err(status_error(status)); + return Err(status_error(status, endpoint)); } let body = read_bounded_response(response, MAX_BUDGET_RESPONSE_BYTES) .await diff --git a/rust/src/providers/vercel/budget_tests.rs b/rust/src/providers/vercel/budget_tests.rs index 6a5b5872999..8a84fa25e00 100644 --- a/rust/src/providers/vercel/budget_tests.rs +++ b/rust/src/providers/vercel/budget_tests.rs @@ -396,7 +396,44 @@ async fn rows_for_other_scopes_are_ignored_even_when_malformed() { } #[tokio::test] -async fn http_failures_map_to_fixed_messages_without_the_body() { +async fn user_endpoint_401_or_403_reads_as_a_rejected_token() { + for (status_line, mentions_scope) in [("401 Unauthorized", false), ("403 Forbidden", true)] { + let (result, heads) = budget(vec![(status_line, "private-response".into())]).await; + let message = result.expect_err(status_line).to_string(); + assert!( + message.contains("rejected the access token"), + "{status_line}: {message}" + ); + assert_eq!( + message.contains("lacks permission to read the current user"), + mentions_scope, + "{status_line}: {message}" + ); + assert!( + !message.contains("non-Contributor"), + "{status_line}: {message}" + ); + assert!(!message.contains("private-response"), "{message}"); + assert_eq!(heads.len(), 1, "the budget list is not requested"); + } +} + +#[tokio::test] +async fn user_endpoint_other_failures_map_to_fixed_messages() { + for (status_line, needle) in [ + ("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 message = result.expect_err(status_line).to_string(); + assert!(message.contains(needle), "{status_line}: {message}"); + assert!(!message.contains("private-response"), "{message}"); + } +} + +#[tokio::test] +async fn budget_list_failures_map_to_fixed_messages_without_the_body() { for (status_line, needle) in [ ("401 Unauthorized", "rejected the access token"), ("403 Forbidden", "non-Contributor role"), @@ -404,11 +441,19 @@ async fn http_failures_map_to_fixed_messages_without_the_body() { ("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(); + let (result, heads) = budget(vec![ + ("200 OK", user_body("user_fixture")), + (status_line, "private-response".into()), + ]) + .await; + let message = result.expect_err(status_line).to_string(); assert!(message.contains(needle), "{status_line}: {message}"); assert!(!message.contains("private-response"), "{message}"); + assert_eq!( + heads.len(), + 2, + "{status_line}: the user lookup succeeded first" + ); } }