Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/desktop-tauri/src-tauri/src/commands/provider_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ fn workspace_provider(provider_id: &str) -> Option<codexbar::core::ProviderId> {
"exa" => ProviderId::Exa,
"cosmic" => ProviderId::Cosmic,
"flora" => ProviderId::Flora,
"vercel" => ProviderId::Vercel,
_ => return None,
})
}
Expand Down Expand Up @@ -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!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 14 additions & 3 deletions docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,9 +862,20 @@ Authorization: Bearer <access token>
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

Expand Down
37 changes: 30 additions & 7 deletions rust/src/providers/vercel/budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)),
Expand Down Expand Up @@ -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<Value> = Vec::new();
Expand All @@ -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)
Expand Down Expand Up @@ -337,6 +359,7 @@ impl VercelProvider {
url: &str,
token: &str,
query: &[(&str, &str)],
endpoint: Endpoint,
) -> Result<Value, ProviderError> {
let response = self
.client
Expand All @@ -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
Expand Down
53 changes: 49 additions & 4 deletions rust/src/providers/vercel/budget_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,19 +396,64 @@ 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"),
("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();
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"
);
}
}

Expand Down