diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index 3ea88ba5df4..4aff28ded60 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -428,7 +428,7 @@ "size": [310, 1032], "steps": [ { "expectText": ["Vercel", "Personal budget", "Spend", "Limit", "Remaining", "$58.00"] }, - { "expectNoText": "Available balance" }, + { "expectNoText": ["Available balance", "in reserve", "Lasts until reset"] }, { "screenshot": "flyout-vercel-budget", "clip": "panel" } ] }, @@ -438,7 +438,11 @@ "size": [880, 1400], "steps": [ { "expectText": ["Vercel personal budget", "Access token", "Vercel team", "Team ID"] }, + { "expectText": ["Spend", "Limit", "Remaining", "$42.00", "$100.00", "$58.00"] }, + { "expectText": ["Credential Storage", "Access tokens"] }, { "expectNoText": "Remove" }, + { "expectNoText": ["On-pace budget", "in reserve"] }, + { "expectNoText": "Cookie source" }, { "screenshot": "settings-vercel-budget" } ] }, diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs index 5eef614e2a9..dac2150551e 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge.rs @@ -387,8 +387,12 @@ pub struct ProviderUsageSnapshot { pub pace: Option, /// Whether the fetch allows pace (upstream `allowsPace(dataConfidence:)`). /// The tray scores its pace tint at presentation time, so it needs this - /// beside the windows. Tray-only; not sent to the frontend. - #[serde(default = "default_pace_authoritative", skip_serializing)] + /// beside the windows. Sent only when false, so the frontend can skip + /// the pace advice it derives from window timing (on-pace budgets). + #[serde( + default = "default_pace_authoritative", + skip_serializing_if = "is_pace_authoritative" + )] pub pace_authoritative: bool, /// The quota percentages are a local estimate (upstream /// `dataConfidence == .estimated`); percent surfaces prefix them with `~`. @@ -469,6 +473,10 @@ fn default_pace_authoritative() -> bool { true } +fn is_pace_authoritative(value: &bool) -> bool { + *value +} + fn default_error_state() -> codexbar::core::ProviderStateKind { codexbar::core::ProviderStateKind::Unknown } diff --git a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs index ffa9391c0f8..02db1d6af79 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/bridge/pace.rs @@ -515,6 +515,38 @@ mod tests { assert!(!pace.reset_window); } + /// A spend budget (Vercel's personal budget) opts out of pace in its + /// fetch result, so the card gets neither a pace marker nor a reserve. + #[test] + fn non_authoritative_spend_budget_has_no_pace_or_reserve() { + let metadata = instantiate_provider(ProviderId::Vercel).metadata().clone(); + let mut budget = monthly(50.0, Some(40_320)); + budget.reset_description = Some("$42.00 / $84.00".into()); + budget.description_is_detail = true; + let result = ProviderFetchResult::new(UsageSnapshot::new(budget), "api") + .with_non_authoritative_pace(); + + let snap = ProviderUsageSnapshot::from_fetch_result_at( + ProviderId::Vercel, + &metadata, + &result, + None, + None, + february_now(), + ); + + assert_eq!(snap.primary.pace, None); + assert_eq!(snap.primary.reserve_percent, None); + assert!(!snap.primary.reserve_will_last_to_reset); + assert!(snap.pace.is_none()); + // The frontend derives on-pace budgets from window timing, so it + // needs the flag as well. + let json = serde_json::to_value(&snap).unwrap(); + assert_eq!(json["paceAuthoritative"], serde_json::Value::Bool(false)); + let cached: ProviderUsageSnapshot = serde_json::from_value(json).unwrap(); + assert!(!cached.pace_authoritative); + } + #[test] fn exhausted_reset_window_has_no_pace() { let usage = UsageSnapshot::new(monthly(100.0, None)); diff --git a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs index f88577dcddf..753449fde03 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/provider_detail.rs @@ -309,6 +309,12 @@ pub fn revoke_provider_credentials( .collect(); forget_provider_accounts(&app, id, &removed_ids); + let mut settings = Settings::load(); + if settings.management_api_token(id).is_some() { + settings.set_management_api_token(id, None); + settings.save().map_err(|e| e.to_string())?; + } + Ok(()) } @@ -318,6 +324,9 @@ pub struct CredentialStorageStatusBridge { pub manual_cookies: String, pub api_keys: String, pub token_accounts: String, + /// Provider access tokens kept in `settings.json` (Vercel personal budget, + /// OpenRouter management key). + pub access_tokens: String, } pub(crate) fn credential_file_status_label(status: SecureFileStatus) -> String { @@ -334,6 +343,23 @@ fn optional_credential_status(path: Option) -> String { .unwrap_or_else(|| "unavailable".to_string()) } +/// Access tokens share `settings.json` with ordinary preferences, so the file +/// existing says nothing about them; report its protection only while a token +/// is actually stored. +pub(crate) fn access_token_status( + settings: &Settings, + settings_file_status: impl FnOnce() -> String, +) -> String { + let any_stored = ProviderId::all() + .iter() + .any(|id| settings.management_api_token(*id).is_some()); + if any_stored { + settings_file_status() + } else { + "missing".to_string() + } +} + #[tauri::command] pub fn get_credential_storage_status() -> CredentialStorageStatusBridge { CredentialStorageStatusBridge { @@ -342,5 +368,8 @@ pub fn get_credential_storage_status() -> CredentialStorageStatusBridge { token_accounts: credential_file_status_label(secure_file::status( &TokenAccountStore::default_path(), )), + access_tokens: access_token_status(&Settings::load(), || { + optional_credential_status(Settings::settings_path()) + }), } } diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests.rs b/apps/desktop-tauri/src-tauri/src/commands/tests.rs index ac922ff1be1..3468714a0ea 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests.rs @@ -116,6 +116,40 @@ fn credential_status_labels_do_not_include_error_details() { ); } +#[test] +fn access_token_status_reports_settings_file_only_while_a_token_is_stored() { + let mut settings = codexbar::settings::Settings::default(); + assert_eq!( + super::access_token_status(&settings, || unreachable!("no token stored")), + "missing" + ); + + settings.set_management_api_token(ProviderId::Vercel, Some("vercel-test-token".into())); + let status = + super::access_token_status(&settings, || "protected:windows-dpapi-user".to_string()); + assert_eq!(status, "protected:windows-dpapi-user"); + assert!(!status.contains("vercel-test-token")); + + settings.set_management_api_token(ProviderId::Vercel, None); + settings.set_management_api_token(ProviderId::OpenRouter, Some("or-test-key".into())); + assert_eq!( + super::access_token_status(&settings, || "plaintext".to_string()), + "plaintext" + ); +} + +#[test] +fn credential_storage_status_serializes_access_tokens() { + let json = serde_json::to_value(super::CredentialStorageStatusBridge { + manual_cookies: "missing".into(), + api_keys: "missing".into(), + token_accounts: "missing".into(), + access_tokens: "protected:windows-dpapi-user".into(), + }) + .expect("serialize"); + assert_eq!(json["accessTokens"], "protected:windows-dpapi-user"); +} + #[test] fn command_inputs_reject_invalid_provider_ids_before_storage_writes() { assert!(super::set_api_key("not-a-provider".into(), "sk-test".into(), None).is_err()); diff --git a/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs b/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs index f1551bc0b8e..70054f186a4 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/tests/cookie_region_options.rs @@ -30,6 +30,25 @@ fn cookie_options_empty_for_providers_without_picker() { assert!(crate::commands::cookie_source_options_for("unknown", Language::English).is_empty()); } +#[test] +fn cookie_source_picker_is_offered_only_to_providers_with_a_cookie_domain() { + for provider in codexbar::core::ProviderId::all() { + let options = + crate::commands::cookie_source_options_for(provider.cli_name(), Language::English); + if !options.is_empty() { + assert!( + provider.cookie_domain().is_some(), + "{} offers a cookie source without a cookie domain", + provider.cli_name() + ); + } + } + assert!(codexbar::core::ProviderId::Vercel.cookie_domain().is_none()); + assert!(crate::commands::cookie_source_options_for("vercel", Language::English).is_empty()); + assert!(codexbar::core::ProviderId::Claude.cookie_domain().is_some()); + assert!(!crate::commands::cookie_source_options_for("claude", Language::English).is_empty()); +} + #[test] fn region_options_for_regional_provider() { let opts = crate::commands::region_options_for("alibaba", Language::English); diff --git a/apps/desktop-tauri/src/components/MenuCard.tsx b/apps/desktop-tauri/src/components/MenuCard.tsx index e825cac7518..d8747691763 100644 --- a/apps/desktop-tauri/src/components/MenuCard.tsx +++ b/apps/desktop-tauri/src/components/MenuCard.tsx @@ -284,7 +284,9 @@ export default function MenuCard({ const metricDisplay = { resetTimeRelative, showResetWhenExhausted, - showPace: showPace && providerAllowsPace(provider.providerId, provider.sourceLabel), + showPace: + showPace && + providerAllowsPace(provider.providerId, provider.sourceLabel, provider.paceAuthoritative), showAsUsed, compactOverview, monthlyLimitBlockNow, diff --git a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx index d8c89432bcd..5e5adadeccd 100644 --- a/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx +++ b/apps/desktop-tauri/src/components/ProviderDisplayRow.tsx @@ -33,21 +33,20 @@ export function groupProviderDisplayDetails( } /** - * One transient provider detail line: "{title}: {value} [secondary]" - * plus an optional clamped progress bar. + * One transient provider detail row for a `
`: the title as `
`, the + * value [secondary] and an optional clamped progress bar as `
`. * - * Used by the settings provider detail (`UsageSection`); the tray card renders - * the same data as key-value rows in `card/DetailSections`. + * Used by the settings provider detail (`UsageSection`) inside a + * `provider-detail-grid`; the tray card renders the same data as key-value + * rows in `card/DetailSections`. */ export function ProviderDisplayRow({ detail, - lineClassName, secondaryClassName, trackClassName, fillClassName, }: { detail: ProviderDisplayDetail; - lineClassName: string; secondaryClassName?: string; trackClassName: string; fillClassName: string; @@ -64,9 +63,10 @@ export function ProviderDisplayRow({ : null; return ( -
-
- {title}: {localizeProviderText(detail.value, t)} +
+
{title}
+
+ {localizeProviderText(detail.value, t)} {detail.secondaryValue && secondaryClassName && ( <> {" "} @@ -75,19 +75,19 @@ export function ProviderDisplayRow({ )} - - {progressPercent != null && ( -
-
-
- )} + {progressPercent != null && ( +
+
+
+ )} +
); } diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index 6d928520a07..85c8c9d25fb 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -1117,6 +1117,7 @@ export const CORE_LOCALE_KEYS = [ "CredentialApiKeys", "CredentialManualCookies", "CredentialTokenAccounts", + "CredentialAccessTokens", "CredentialProtectedPrefix", "CredentialStatusNotCreated", "CredentialStatusPlaintext", diff --git a/apps/desktop-tauri/src/lib/providerPace.test.ts b/apps/desktop-tauri/src/lib/providerPace.test.ts index 4e90f997d07..9e70105ece7 100644 --- a/apps/desktop-tauri/src/lib/providerPace.test.ts +++ b/apps/desktop-tauri/src/lib/providerPace.test.ts @@ -11,5 +11,11 @@ describe("providerAllowsPace", () => { expect(providerAllowsPace("opencodego", "web")).toBe(true); expect(providerAllowsPace("claude", "local estimate")).toBe(true); expect(providerAllowsPace("opencodego", null)).toBe(true); + expect(providerAllowsPace("claude", "oauth", true)).toBe(true); + expect(providerAllowsPace("claude", "oauth", undefined)).toBe(true); + }); + + it("rejects snapshots the backend marks as not pace-authoritative", () => { + expect(providerAllowsPace("vercel", "api", false)).toBe(false); }); }); diff --git a/apps/desktop-tauri/src/lib/providerPace.ts b/apps/desktop-tauri/src/lib/providerPace.ts index d2579059547..d6be75ef419 100644 --- a/apps/desktop-tauri/src/lib/providerPace.ts +++ b/apps/desktop-tauri/src/lib/providerPace.ts @@ -1,8 +1,14 @@ -/** Device-local OpenCode Go quota estimates do not establish account-wide pace. */ +/** + * Whether a provider snapshot may show pace advice. The backend marks + * non-authoritative fetches with `paceAuthoritative: false`; device-local + * OpenCode Go quota estimates do not establish account-wide pace either. + */ export function providerAllowsPace( providerId: string, sourceLabel: string | null | undefined, + paceAuthoritative?: boolean, ): boolean { + if (paceAuthoritative === false) return false; return !( providerId === "opencodego" && sourceLabel?.trim().toLowerCase() === "local estimate" diff --git a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts index 9afd739a7c0..6cf6f8a5c65 100644 --- a/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts +++ b/apps/desktop-tauri/src/proof/fixtures/bridgeData.ts @@ -108,6 +108,8 @@ export function providerDetail( namedExtraMetrics: providerId === "cursor" ? [{ id: "cursor-grok-bot", label: "Grok Bot" }] : [], extraRateWindows: snapshot?.extraRateWindows ?? [], + inventory: snapshot?.inventory ?? [], + displayDetails: snapshot?.displayDetails ?? [], usageItems: [], hiddenUsageItemIds: [], cost: snapshot?.cost ?? null, diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 1ec4e3cf4a2..ec2e7dfe40f 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -247,6 +247,7 @@ function vercelSnapshot(now: Date): ProviderUsageSnapshot { secondary: null, accountOrganization: "Example Team", sourceLabel: "api", + paceAuthoritative: false, displayDetails: [ { id: "vercel-budget-spend", diff --git a/apps/desktop-tauri/src/proof/router.test.ts b/apps/desktop-tauri/src/proof/router.test.ts index 5aa080fc642..1b8df9fcce1 100644 --- a/apps/desktop-tauri/src/proof/router.test.ts +++ b/apps/desktop-tauri/src/proof/router.test.ts @@ -95,6 +95,18 @@ describe("proof router", () => { expect(openrouter.email).toBeNull(); }); + it("offers cookie-source options only to providers with a cookie domain", () => { + const router = routerFor("vercel", "settings"); + const options = (providerId: string) => + router.handle("get_provider_cookie_source_options", { providerId }) as unknown[]; + expect(options("claude").length).toBeGreaterThan(0); + expect(options("vercel")).toEqual([]); + expect( + (router.handle("get_provider_detail", { providerId: "vercel" }) as ProviderDetail) + .cookieSource, + ).toBeNull(); + }); + it("applies settings patches to later reads", () => { const router = routerFor("default"); router.handle("update_settings", { patch: { theme: "light" } }); diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts index fb78bbebcb0..1cb3c815051 100644 --- a/apps/desktop-tauri/src/proof/router.ts +++ b/apps/desktop-tauri/src/proof/router.ts @@ -285,6 +285,7 @@ export function createProofRouter( manualCookies: "Windows Credential Manager", apiKeys: "Windows Credential Manager", tokenAccounts: "Windows Credential Manager", + accessTokens: "missing", }), get_provider_detail: (args) => { const detail = providerDetail(current(), stringArg(args, "providerId")); @@ -302,11 +303,19 @@ export function createProofRouter( get_usage_spend_summary: () => usageSpendSummary(current(), now), get_spend_contract: (args) => spendContract(stringArg(args, "providerId"), settings.spendDashboardPeriod ?? "rolling:30"), - get_provider_cookie_source_options: () => [ - { value: "auto", label: "Automatic" }, - { value: "manual", label: "Manual" }, - { value: "off", label: "Off" }, - ], + // The real bridge offers no cookie-source options for providers that + // never read browser cookies, which hides the picker. + get_provider_cookie_source_options: (args) => { + const providerId = stringArg(args, "providerId"); + const entry = current().catalog.find((candidate) => candidate.id === providerId); + return entry?.cookieDomain + ? [ + { value: "auto", label: "Automatic" }, + { value: "manual", label: "Manual" }, + { value: "off", label: "Off" }, + ] + : []; + }, get_provider_region_options: () => [], get_provider_workspace_id: () => null, get_provider_gateway_url: () => "", diff --git a/apps/desktop-tauri/src/styles/providers-tab.css b/apps/desktop-tauri/src/styles/providers-tab.css index 601f62c81ed..5ce85cef282 100644 --- a/apps/desktop-tauri/src/styles/providers-tab.css +++ b/apps/desktop-tauri/src/styles/providers-tab.css @@ -168,6 +168,25 @@ margin-top: 4px; } +.provider-detail-grid--usage { + margin: 4px 0 8px; +} + +.provider-detail-grid--usage dd { + word-break: normal; + overflow-wrap: anywhere; +} + +.provider-detail-grid--usage .provider-usage-bar__track { + margin-top: 3px; +} + +.provider-usage-inventory { + margin: 4px 0; + font-size: 0.78rem; + color: var(--text-primary); +} + @media (max-width: 560px) { .provider-split { gap: 10px; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx index 7490a21c75f..349134ea316 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderDetail, SettingsSnapshot } from "../../../types/bridge"; @@ -34,7 +34,13 @@ vi.mock("./sections/UsageDetailsSection", () => ({ UsageDetailsSection: () => nu vi.mock("./sections/UsageSourceSection", () => ({ UsageSourceSection: stub("usage-source") })); vi.mock("./sections/CookieSourceSection", () => ({ CookieSourceSection: () => null })); vi.mock("./sections/RegionSection", () => ({ RegionSection: () => null })); -vi.mock("./sections/CredentialsDispatcher", () => ({ CredentialsDispatcher: () => null })); +vi.mock("./sections/CredentialsDispatcher", () => ({ + CredentialsDispatcher: ({ onCredentialsChanged }: { onCredentialsChanged?: () => void }) => ( + + ), +})); vi.mock("./ApiKeySection", () => ({ ApiKeySection: stub("api-key") })); vi.mock("./CookieSection", () => ({ CookieSection: () => null })); vi.mock("../tokens/TokenAccountsPanel", () => ({ TokenAccountsPanel: () => null })); @@ -252,6 +258,68 @@ describe("ProviderDetailPane", () => { expect(screen.getByTestId("claude-accounts")).toHaveAttribute("data-path", "C:/tools/cswap.exe"); }); + it("reloads the credential storage summary after a stored token changes", async () => { + const storage = (accessTokens: string) => ({ + apiKeys: "missing", + manualCookies: "missing", + tokenAccounts: "missing", + accessTokens, + }); + tauri.getProviderDetail.mockResolvedValue(detail("codex")); + tauri.getCredentialStorageStatus.mockResolvedValue(storage("missing")); + await renderPane("codex"); + const accessTokens = () => screen.getByText("CredentialAccessTokens").nextElementSibling; + expect(accessTokens()).toHaveTextContent("CredentialStatusNotCreated"); + + tauri.getCredentialStorageStatus.mockResolvedValue(storage("protected:windows-dpapi-user")); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "stub-token-saved" })), + ); + + await waitFor(() => + expect(accessTokens()).toHaveTextContent("CredentialProtectedPrefix (windows-dpapi-user)"), + ); + expect(tauri.getCredentialStorageStatus).toHaveBeenCalledTimes(2); + }); + + it("ignores a credential reload that finishes after another provider was selected", async () => { + tauri.getProviderDetail.mockResolvedValue(detail("codex")); + const view = await renderPane("codex"); + + let finishOldLoad: (value: ProviderDetail) => void = () => {}; + tauri.getProviderDetail.mockReturnValueOnce( + new Promise((resolve) => { + finishOldLoad = resolve; + }), + ); + await act(async () => + fireEvent.click(screen.getByRole("button", { name: "stub-token-saved" })), + ); + + tauri.getProviderDetail.mockResolvedValue(detail("claude")); + view.rerender( + , + ); + await screen.findByText("Claude"); + + await act(async () => finishOldLoad(detail("codex"))); + + expect(screen.getByText("Claude")).toBeInTheDocument(); + expect(screen.queryByText("Codex")).toBeNull(); + }); + it("locks Avoid keychain prompts while Credential Manager access is disabled", async () => { tauri.getProviderDetail.mockResolvedValue(detail("claude")); const settings = { ...baseSettings, disableKeychainAccess: true } as SettingsSnapshot; diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx index 50cf6f3a05c..82a82e3de60 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/ProviderDetailPane.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useReducer, useState } from "react"; +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; import type { ProviderCatalogEntry, ProviderUsageSnapshot, @@ -143,7 +143,13 @@ export function ProviderDetailPane({ const setBusy = (value: boolean) => dispatch({ type: "SET_BUSY", busy: value }); + // A load started for another provider (e.g. a credential save that finished + // after the selection changed) must not replace the current provider. + const selectedProviderId = useRef(providerId); + selectedProviderId.current = providerId; + const load = useCallback(async (id: string, signal?: { stale: boolean }) => { + const isStale = () => signal?.stale === true || selectedProviderId.current !== id; dispatch({ type: "LOAD_START" }); try { const [next, cookieOpts, regionOpts, storageStatus] = await Promise.all([ @@ -152,7 +158,7 @@ export function ProviderDetailPane({ getProviderRegionOptions(id), getCredentialStorageStatus(), ]); - if (signal?.stale) return; + if (isStale()) return; dispatch({ type: "LOAD_SUCCESS", detail: next, @@ -161,12 +167,12 @@ export function ProviderDetailPane({ credentialStatus: storageStatus, }); } catch (e) { - if (signal?.stale) return; + if (isStale()) return; dispatch({ type: "LOAD_ERROR", error: String(e) }); } finally { // Keep stale guard — concurrent provider switches must not clear loading // for the in-flight replacement load. - if (!signal?.stale) dispatch({ type: "LOAD_FINISH" }); + if (!isStale()) dispatch({ type: "LOAD_FINISH" }); } }, []); @@ -510,7 +516,11 @@ export function ProviderDetailPane({ - + { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx index a278f5cb923..bfed24cb5b0 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CookieSourceSection.test.tsx @@ -83,6 +83,31 @@ describe("CookieSourceSection manual cookie hint", () => { ).not.toBeInTheDocument(); }); + it("renders the picker for a cookie provider and nothing without options", () => { + const { unmount } = render( + key} + onChanged={vi.fn()} + />, + ); + expect(screen.getByRole("radiogroup", { name: "ProviderCookieSource" })).toBeInTheDocument(); + unmount(); + + const { container } = render( + key} + onChanged={vi.fn()} + />, + ); + expect(container).toBeEmptyDOMElement(); + }); + it("wraps the segmented options inside the narrow detail pane", () => { const rule = ruleBlock(loadStyles(), ".provider-detail-segmented"); expect(rule).toMatch(/flex-wrap:\s*wrap;/); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.test.tsx new file mode 100644 index 00000000000..cceb4d87412 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CredentialStorageSection } from "./CredentialStorageSection"; + +describe("CredentialStorageSection", () => { + it("lists stored access tokens next to the other credential stores", () => { + render( + key} + />, + ); + + const term = screen.getByText("CredentialAccessTokens"); + expect(term.tagName).toBe("DT"); + expect(term.nextElementSibling).toHaveTextContent( + "CredentialProtectedPrefix (windows-dpapi-user)", + ); + expect(screen.getByText("CredentialApiKeys").nextElementSibling).toHaveTextContent( + "CredentialStatusNotCreated", + ); + }); + + it("reports access tokens as not created when none is stored", () => { + render( + key} + />, + ); + + expect(screen.getByText("CredentialAccessTokens").nextElementSibling).toHaveTextContent( + "CredentialStatusNotCreated", + ); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.tsx index 53b148090dd..0e43881c292 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialStorageSection.tsx @@ -36,6 +36,8 @@ export function CredentialStorageSection({
{storageLabel(status.manualCookies, t)}
{t("CredentialTokenAccounts")}
{storageLabel(status.tokenAccounts, t)}
+
{t("CredentialAccessTokens")}
+
{storageLabel(status.accessTokens, t)}
); 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 e3c2b531feb..7c801fca9cc 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/CredentialsDispatcher.tsx @@ -10,6 +10,8 @@ import { VercelAccessTokenCreds } from "./credentials/VercelAccessTokenCreds"; interface Props { providerId: string; t: (key: LocaleKey) => string; + /** Called after a stored secret was saved or removed. */ + onCredentialsChanged?: () => void; } /** @@ -17,7 +19,7 @@ interface Props { * current provider. Providers without a bespoke credentials UI render * nothing. Claude and Codex switches live in the Options group instead. */ -export function CredentialsDispatcher({ providerId, t }: Props) { +export function CredentialsDispatcher({ providerId, t, onCredentialsChanged }: Props) { switch (providerId) { case "gemini": return ; @@ -28,11 +30,11 @@ export function CredentialsDispatcher({ providerId, t }: Props) { case "kiro": return ; case "openrouter": - return ; + return ; case "vercel": return ( <> - + ); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.test.tsx index 33f9a5896e5..233a8d6ffcd 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.test.tsx @@ -116,6 +116,25 @@ describe("UsageDetailsSection", () => { expect(screen.getByRole("img", { name: /PaceChartAriaLabel/i })).toBeInTheDocument(); }); + it("skips the on-pace budget when the snapshot is not pace-authoritative", () => { + // A Vercel spend budget: a monthly window with a reset, but no pace. + const resetsAt = new Date(Date.now() + 17 * 24 * 60 * 60 * 1000).toISOString(); + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + expect(screen.queryByText(/On-pace budget/)).not.toBeInTheDocument(); + }); + it("skips budgets for windows shorter than a week", () => { const resetsAt = new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(); const { container } = render( diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx index e7ae1abc8fa..49e3019273e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageDetailsSection.tsx @@ -209,7 +209,11 @@ export function UsageDetailsSection({ ) : null; } - const paceAllowed = providerAllowsPace(provider.providerId, provider.sourceLabel); + const paceAllowed = providerAllowsPace( + provider.providerId, + provider.sourceLabel, + provider.paceAuthoritative, + ); const budgets = paceAllowed ? weeklyBudgets(provider, t, language, monthlyLimitBlockNow) : []; const forecast = paceAllowed ? formatSessionEquivalentEstimate(provider.sessionEquivalentForecast) diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx index 4f8d5ac7d4e..a53991b37ab 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.test.tsx @@ -68,6 +68,7 @@ describe("UsageSection", () => { UsageBarQuotaWarnings: "Quota warnings", ProviderLabelLimitResetCredits: "Limit reset credits", ProviderTextTokens: "{} tokens", + UsageSpendSpend: "Spend", }), ); eventMocks.listen.mockResolvedValue(() => {}); @@ -341,9 +342,70 @@ describe("UsageSection", () => { , ); - expect(await screen.findByText(/fixture-alpha: 60 tokens/)).toBeInTheDocument(); + const alpha = await screen.findByText("fixture-alpha"); + expect(alpha.tagName).toBe("DT"); + expect(alpha.closest("dl")).toHaveClass("provider-detail-grid"); + expect(screen.getByText("60 tokens").tagName).toBe("DD"); expect(screen.getAllByRole("heading", { name: "Model activity" })).toHaveLength(1); - expect(screen.getByText(/Plain: 1/)).toBeInTheDocument(); + expect(screen.getByText("Plain").tagName).toBe("DT"); + expect(screen.getByText("1").tagName).toBe("DD"); + }); + + it("lays out spend budget details in the styled detail grid", async () => { + const detail = provider(); + detail.id = "vercel"; + detail.session = null; + detail.extraRateWindows = []; + detail.displayDetails = [ + { + id: "spend", + sectionTitle: null, + title: "Spend", + value: "$42.00", + secondaryValue: null, + progress: { used: 42, total: 100 }, + }, + { + id: "limit", + sectionTitle: null, + title: "Limit", + value: "$100.00", + secondaryValue: null, + progress: null, + }, + { + id: "remaining", + sectionTitle: null, + title: "Remaining", + value: "$58.00", + secondaryValue: null, + progress: null, + }, + ]; + + const { container } = render( + + key} /> + , + ); + + await screen.findByText("Spend"); + const grid = container.querySelector("dl.provider-detail-grid"); + expect(grid).not.toBeNull(); + expect(Array.from(grid?.querySelectorAll("dt") ?? []).map((dt) => dt.textContent)).toEqual([ + "Spend", + "Limit", + "Remaining", + ]); + expect(Array.from(grid?.querySelectorAll("dd") ?? []).map((dd) => dd.textContent)).toEqual([ + "$42.00", + "$100.00", + "$58.00", + ]); + const bar = screen.getByRole("progressbar", { name: "Spend" }); + expect(bar.closest("dd")).not.toBeNull(); + expect(bar).toHaveAttribute("aria-valuenow", "42"); + expect(container.querySelector(".provider-usage-inventory")).toBeNull(); }); it("draws Custom quota-warning markers at % left and follows show-as-used", async () => { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx index bd9fc921fc0..bd3e827a6b4 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/UsageSection.tsx @@ -156,15 +156,16 @@ export function UsageSection({ {localizeProviderLabel(group.title, t)} )} - {group.rows.map((detail) => ( - - ))} +
+ {group.rows.map((detail) => ( + + ))} +
))} diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.test.tsx new file mode 100644 index 00000000000..29799677d1a --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.test.tsx @@ -0,0 +1,36 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauri = vi.hoisted(() => ({ + hasOpenRouterManagementApiKey: vi.fn(), + setOpenRouterManagementApiKey: vi.fn(), + removeOpenRouterManagementApiKey: vi.fn(), +})); +vi.mock("../../../../../lib/tauri", () => tauri); + +import { OpenRouterManagementCreds } from "./OpenRouterManagementCreds"; + +describe("OpenRouterManagementCreds", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauri.setOpenRouterManagementApiKey.mockResolvedValue(undefined); + tauri.removeOpenRouterManagementApiKey.mockResolvedValue(undefined); + }); + + it("reports saved and removed keys so the storage summary can refresh", async () => { + tauri.hasOpenRouterManagementApiKey.mockResolvedValue(false); + const onChanged = vi.fn(); + render( key} onChanged={onChanged} />); + + fireEvent.change(screen.getByLabelText("OpenRouterManagementKeyLabel"), { + target: { value: "or-test-key" }, + }); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Save" }))); + expect(tauri.setOpenRouterManagementApiKey).toHaveBeenCalledWith("or-test-key"); + expect(onChanged).toHaveBeenCalledTimes(1); + + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Remove" }))); + expect(tauri.removeOpenRouterManagementApiKey).toHaveBeenCalledTimes(1); + expect(onChanged).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.tsx index 7b2e390c477..e862e277e2e 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/OpenRouterManagementCreds.tsx @@ -8,9 +8,10 @@ import { interface Props { t: (key: LocaleKey) => string; + onChanged?: () => void; } -export function OpenRouterManagementCreds({ t }: Props) { +export function OpenRouterManagementCreds({ t, onChanged }: Props) { const [configured, setConfigured] = useState(false); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); @@ -39,6 +40,7 @@ export function OpenRouterManagementCreds({ t }: Props) { await setOpenRouterManagementApiKey(next); setConfigured(true); setValue(""); + onChanged?.(); } catch (cause: unknown) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -53,6 +55,7 @@ export function OpenRouterManagementCreds({ t }: Props) { await removeOpenRouterManagementApiKey(); setConfigured(false); setValue(""); + onChanged?.(); } catch (cause: unknown) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { diff --git a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.test.tsx b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.test.tsx new file mode 100644 index 00000000000..6d4694f3e02 --- /dev/null +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.test.tsx @@ -0,0 +1,60 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const tauri = vi.hoisted(() => ({ + hasVercelAccessToken: vi.fn(), + setVercelAccessToken: vi.fn(), + removeVercelAccessToken: vi.fn(), +})); +vi.mock("../../../../../lib/tauri", () => tauri); + +import { VercelAccessTokenCreds } from "./VercelAccessTokenCreds"; + +describe("VercelAccessTokenCreds", () => { + beforeEach(() => { + vi.clearAllMocks(); + tauri.setVercelAccessToken.mockResolvedValue(undefined); + tauri.removeVercelAccessToken.mockResolvedValue(undefined); + }); + + it("reports a saved token so the storage summary can refresh", async () => { + tauri.hasVercelAccessToken.mockResolvedValue(false); + const onChanged = vi.fn(); + render( key} onChanged={onChanged} />); + + fireEvent.change(screen.getByLabelText("VercelAccessTokenLabel"), { + target: { value: " vercel-test-token " }, + }); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Save" }))); + + expect(tauri.setVercelAccessToken).toHaveBeenCalledWith("vercel-test-token"); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it("reports a removed token so the storage summary can refresh", async () => { + tauri.hasVercelAccessToken.mockResolvedValue(true); + const onChanged = vi.fn(); + render( key} onChanged={onChanged} />); + + const removeButton = await screen.findByRole("button", { name: "Remove" }); + await act(async () => fireEvent.click(removeButton)); + + expect(tauri.removeVercelAccessToken).toHaveBeenCalledTimes(1); + expect(onChanged).toHaveBeenCalledTimes(1); + }); + + it("does not report a change when saving fails", async () => { + tauri.hasVercelAccessToken.mockResolvedValue(false); + tauri.setVercelAccessToken.mockRejectedValue(new Error("Vercel access token must not be empty")); + const onChanged = vi.fn(); + render( key} onChanged={onChanged} />); + + fireEvent.change(screen.getByLabelText("VercelAccessTokenLabel"), { + target: { value: "x" }, + }); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Save" }))); + + expect(onChanged).not.toHaveBeenCalled(); + expect(screen.getByText("Vercel access token must not be empty")).toBeInTheDocument(); + }); +}); 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 index 918acba76c5..2a1858d78a1 100644 --- a/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx +++ b/apps/desktop-tauri/src/surfaces/settings/providers/sections/credentials/VercelAccessTokenCreds.tsx @@ -8,13 +8,14 @@ import { interface Props { t: (key: LocaleKey) => string; + onChanged?: () => void; } /** * 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) { +export function VercelAccessTokenCreds({ t, onChanged }: Props) { const [configured, setConfigured] = useState(false); const [value, setValue] = useState(""); const [busy, setBusy] = useState(false); @@ -43,6 +44,7 @@ export function VercelAccessTokenCreds({ t }: Props) { await setVercelAccessToken(next); setConfigured(true); setValue(""); + onChanged?.(); } catch (cause: unknown) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { @@ -57,6 +59,7 @@ export function VercelAccessTokenCreds({ t }: Props) { await removeVercelAccessToken(); setConfigured(false); setValue(""); + onChanged?.(); } catch (cause: unknown) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { diff --git a/apps/desktop-tauri/src/types/bridge/usage.ts b/apps/desktop-tauri/src/types/bridge/usage.ts index f41055ecc6b..f066e9a61ca 100644 --- a/apps/desktop-tauri/src/types/bridge/usage.ts +++ b/apps/desktop-tauri/src/types/bridge/usage.ts @@ -222,6 +222,11 @@ export interface ProviderUsageSnapshot { * estimated percentages with `~`. */ quotaEstimated?: boolean; + /** + * Sent only as `false`, when the fetch does not support pace advice (for + * example a spend budget). Absent means pace is allowed. + */ + paceAuthoritative?: boolean; updatedAt: string; error: string | null; errorState: ProviderStateKind; @@ -388,4 +393,6 @@ export interface CredentialStorageStatus { manualCookies: string; apiKeys: string; tokenAccounts: string; + /** Provider access tokens stored in settings (Vercel, OpenRouter management). */ + accessTokens: string; } diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 9d4dc3b70f4..ec4587992f0 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -1404,6 +1404,7 @@ locale_keys! { CredentialApiKeys, CredentialManualCookies, CredentialTokenAccounts, + CredentialAccessTokens, CredentialProtectedPrefix, CredentialStatusNotCreated, CredentialStatusPlaintext, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index 6455fc1f37b..6ccc321fad5 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -890,6 +890,7 @@ CredentialRevokeStored = Revoke Stored Credentials CredentialApiKeys = API keys CredentialManualCookies = Manual cookies CredentialTokenAccounts = Token accounts +CredentialAccessTokens = Access tokens CredentialProtectedPrefix = Protected CredentialStatusNotCreated = Not created CredentialStatusPlaintext = Plaintext diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index c29adc567b4..b25f72fe3a9 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -911,6 +911,7 @@ CredentialRevokeStored = Revocar credenciales almacenadas CredentialApiKeys = Claves API CredentialManualCookies = Cookies manuales CredentialTokenAccounts = Cuentas de token +CredentialAccessTokens = Tokens de acceso CredentialProtectedPrefix = Protegido CredentialStatusNotCreated = No creada CredentialStatusPlaintext = Texto plano diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index a3e9bef6fc2..1ed3fb2de1e 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -858,6 +858,7 @@ CredentialRevokeStored = 保存済み認証情報を取り消し CredentialApiKeys = API キー CredentialManualCookies = 手動 Cookie CredentialTokenAccounts = トークンアカウント +CredentialAccessTokens = アクセストークン CredentialProtectedPrefix = 保護 CredentialStatusNotCreated = 未作成 CredentialStatusPlaintext = 平文 diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 31e9f97c79c..e84a65f311f 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -911,6 +911,7 @@ CredentialRevokeStored = 저장된 자격 증명 해제 CredentialApiKeys = API 키 CredentialManualCookies = 수동 쿠키 CredentialTokenAccounts = 토큰 계정 +CredentialAccessTokens = 액세스 토큰 CredentialProtectedPrefix = 보호됨 CredentialStatusNotCreated = 생성되지 않음 CredentialStatusPlaintext = 일반 텍스트 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 2f1ff61a9f1..fb915ce7516 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -856,6 +856,7 @@ CredentialRevokeStored = Revogar credenciais armazenadas CredentialApiKeys = Chaves de API CredentialManualCookies = Cookies manuais CredentialTokenAccounts = Contas de token +CredentialAccessTokens = Tokens de acesso CredentialProtectedPrefix = Protegido CredentialStatusNotCreated = Não criado CredentialStatusPlaintext = Texto simples diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index 6bb7a6ee584..41458fd4cde 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -890,6 +890,7 @@ CredentialRevokeStored = Отозвать сохраненные учетные CredentialApiKeys = Ключи API CredentialManualCookies = Ручные куки CredentialTokenAccounts = Токен-аккаунты +CredentialAccessTokens = Токены доступа CredentialProtectedPrefix = Защищено CredentialStatusNotCreated = Не создано CredentialStatusPlaintext = Открытый текст diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 0f43a8e8c3e..92191d716f4 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -890,6 +890,7 @@ CredentialRevokeStored = Kayıtlı Kimlik Bilgilerini İptal Et CredentialApiKeys = API anahtarları CredentialManualCookies = Elle eklenen çerezler CredentialTokenAccounts = Token hesapları +CredentialAccessTokens = Erişim tokenları CredentialProtectedPrefix = Korumalı CredentialStatusNotCreated = Oluşturulmadı CredentialStatusPlaintext = Düz metin diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 3f75784f4cb..53b5e25f688 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -890,6 +890,7 @@ CredentialRevokeStored = Відкликати збережені обліков CredentialApiKeys = API-ключі CredentialManualCookies = Cookie, введені вручну CredentialTokenAccounts = Облікові записи з токенами +CredentialAccessTokens = Токени доступу CredentialProtectedPrefix = Захищено CredentialStatusNotCreated = Не створено CredentialStatusPlaintext = Відкритий текст diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index d0d08675796..92a4e14d69d 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -858,6 +858,7 @@ CredentialRevokeStored = 撤销已存储的凭据 CredentialApiKeys = API 密钥 CredentialManualCookies = 手动 Cookie CredentialTokenAccounts = 令牌账户 +CredentialAccessTokens = 访问令牌 CredentialProtectedPrefix = 受保护 CredentialStatusNotCreated = 未创建 CredentialStatusPlaintext = 明文 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 6d2e2a7f1e5..07fb42d97ef 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -858,6 +858,7 @@ CredentialRevokeStored = 撤銷已儲存的憑證 CredentialApiKeys = API 金鑰 CredentialManualCookies = 手動 Cookie CredentialTokenAccounts = 權杖賬戶 +CredentialAccessTokens = 存取權杖 CredentialProtectedPrefix = 受保護 CredentialStatusNotCreated = 未建立 CredentialStatusPlaintext = 明文 diff --git a/rust/src/providers/vercel/budget.rs b/rust/src/providers/vercel/budget.rs index 095f3e296e7..fabec82f40d 100644 --- a/rust/src/providers/vercel/budget.rs +++ b/rust/src/providers/vercel/budget.rs @@ -33,6 +33,7 @@ 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."; +const CURSOR_LOOP: &str = "Vercel's budget list repeated a page cursor. CodexBar stopped reading to avoid a loop, so your personal budget could not be determined."; /// The access token from settings, else `VERCEL_ACCESS_TOKEN`. Blank values /// count as unconfigured, so the team-credit path keeps running. @@ -79,6 +80,14 @@ fn parse_failure() -> ProviderError { ProviderError::Parse(UNRECOGNIZED.into()) } +/// The list did not end within the page cap, so a custom budget on a later +/// page could still override what was seen. +fn page_cap_reached() -> ProviderError { + ProviderError::Other(format!( + "Vercel's budget list for this team has more than {MAX_BUDGET_PAGES} pages. CodexBar stopped reading before the end of the list, so your personal budget could not be determined." + )) +} + /// 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 @@ -284,7 +293,11 @@ fn build_result( 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())) + // A spend budget has no expected consumption curve, so the shell's + // elapsed-time pace would invent "in reserve" and run-out advice. + Ok(ProviderFetchResult::new(usage, "api") + .with_non_authoritative_pace() + .with_display_details(rows.into_iter().flatten())) } impl VercelProvider { @@ -310,7 +323,7 @@ impl VercelProvider { let mut seen_cursors: HashSet = HashSet::new(); let mut cursor: Option = None; let mut finished = false; - for page in 0..MAX_BUDGET_PAGES { + for _ in 0..MAX_BUDGET_PAGES { let mut query = vec![ ("teamId", team.as_str()), ("scopeType", "user"), @@ -331,23 +344,24 @@ impl VercelProvider { 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()); - } + // An empty cursor ends the list like a null one does. + let next = match body.get("nextCursor") { + None | Some(Value::Null) => None, + Some(Value::String(next)) if next.is_empty() => None, + Some(Value::String(next)) => Some(next.clone()), Some(_) => return Err(parse_failure()), + }; + let Some(next) = next else { + finished = true; + break; + }; + if !seen_cursors.insert(next.clone()) { + return Err(ProviderError::Other(CURSOR_LOOP.into())); } + cursor = Some(next); } if !finished { - return Err(parse_failure()); + return Err(page_cap_reached()); } let budget = effective_budget(&matches)?; diff --git a/rust/src/providers/vercel/budget_tests.rs b/rust/src/providers/vercel/budget_tests.rs index 8a84fa25e00..9ae0b129350 100644 --- a/rust/src/providers/vercel/budget_tests.rs +++ b/rust/src/providers/vercel/budget_tests.rs @@ -221,6 +221,10 @@ fn native_user_id_budget_is_matched_and_rendered() { Some("fixture@example.com") ); assert_eq!(result.source_label, "api"); + assert!( + !result.pace_authoritative, + "a spend budget must not get pace or reserve advice" + ); }); } @@ -259,6 +263,7 @@ async fn no_matching_budget_is_the_unconfigured_state_not_zero_spend() { .await; assert!(result.usage.primary.is_informational); assert!(!result.usage.primary.usage_known); + assert!(!result.pace_authoritative); let details = result.display_details(); assert_eq!(details.len(), 1); assert_eq!(details[0].title(), "No personal budget configured"); @@ -346,27 +351,122 @@ async fn pagination_follows_the_cursor_and_stops_when_it_is_null() { assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); } +fn other_message(result: Result) -> String { + match result { + Err(ProviderError::Other(message)) => message, + other => panic!("expected a plain error, got {:?}", other.map(|_| ())), + } +} + #[tokio::test] -async fn a_repeated_cursor_fails_the_refresh() { - let (result, _) = budget(vec![ +async fn a_match_on_page_three_is_found() { + 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![], Some("cursor-3"))), + ( + "200 OK", + page( + vec![budget_row("usr_fixtureUser", 42.0, 84.0, "monthly")], + None, + ), + ), + ]) + .await; + assert_eq!(heads.len(), 4); + assert!(heads[3].contains("cursor=cursor-3"), "{}", heads[3]); + assert!((result.usage.primary.used_percent - 50.0).abs() < 1e-9); +} + +#[tokio::test] +async fn an_empty_cursor_ends_the_list_without_a_match() { + 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![], Some(""))), + ]) + .await; + assert_eq!(heads.len(), 3); + assert!(result.usage.primary.is_informational); + assert_eq!( + result.display_details()[0].title(), + "No personal budget configured" + ); +} + +#[tokio::test] +async fn an_empty_cursor_after_a_match_keeps_the_match() { + let (result, heads) = budget_ok(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + page( + vec![budget_row("fixtureUser", 30.0, 120.0, "monthly")], + Some(""), + ), + ), + ]) + .await; + assert_eq!(heads.len(), 2); + assert!((result.usage.primary.used_percent - 25.0).abs() < 1e-9); +} + +#[tokio::test] +async fn a_repeated_cursor_stops_with_a_loop_message() { + let (result, heads) = budget(vec![ ("200 OK", user_body("fixtureUser")), ("200 OK", page(vec![], Some("same"))), ("200 OK", page(vec![], Some("same"))), ]) .await; - assert_parse_failure(result); + assert_eq!(heads.len(), 3); + let message = other_message(result); + assert_eq!(message, CURSOR_LOOP); + assert!(!message.starts_with("Parse error")); } #[tokio::test] -async fn pagination_is_capped_at_twenty_pages() { +async fn pagination_is_capped_at_twenty_pages_with_its_own_message() { 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 rows = if index == 0 { + vec![budget_row("fixtureUser", 30.0, 120.0, "monthly")] + } else { + vec![] + }; + responses.push(("200 OK", page(rows, Some(&next)))); } let (result, heads) = budget(responses).await; - assert_parse_failure(result); assert_eq!(heads.len(), 1 + MAX_BUDGET_PAGES); + let message = other_message(result); + assert!(message.contains("more than 20 pages"), "{message}"); + assert!(!message.contains("unrecognized"), "{message}"); +} + +#[tokio::test] +async fn a_non_string_cursor_is_still_a_parse_failure() { + let (result, _) = budget(vec![ + ("200 OK", user_body("fixtureUser")), + ( + "200 OK", + json!({"budgets": [], "nextCursor": 7}).to_string(), + ), + ]) + .await; + assert_parse_failure(result); } #[tokio::test]