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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop-tauri/scripts/proof-scenarios.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 28 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 @@ -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<codexbar::core::ProviderId, String> {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop-tauri/src/i18n/keyGroups/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop-tauri/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@ export function removeOpenRouterManagementApiKey(): Promise<void> {
return invoke<void>("remove_openrouter_management_api_key");
}

export function hasVercelAccessToken(): Promise<boolean> {
return invoke<boolean>("has_vercel_access_token");
}

export function setVercelAccessToken(accessToken: string): Promise<void> {
return invoke<void>("set_vercel_access_token", { accessToken });
}

export function removeVercelAccessToken(): Promise<void> {
return invoke<void>("remove_vercel_access_token");
}

export function getManualCookies(): Promise<CookieInfoBridge[]> {
return invoke<CookieInfoBridge[]>("get_manual_cookies");
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop-tauri/src/proof/fixtures/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand Down
62 changes: 62 additions & 0 deletions apps/desktop-tauri/src/proof/fixtures/scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -805,6 +866,7 @@ const BUILDERS: Record<ProofScenarioName, (now: Date) => ProofScenario> = {
"kpi-long-titles": kpiLongTitlesScenario,
claudeplatform: claudePlatformScenario,
"cookie-no-browsers": cookieNoBrowsersScenario,
vercel: vercelScenario,
};

export function isProofScenarioName(value: string): value is ProofScenarioName {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop-tauri/src/proof/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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 ?? [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +29,13 @@ export function CredentialsDispatcher({ providerId, t }: Props) {
return <KiroCreds t={t} />;
case "openrouter":
return <OpenRouterManagementCreds t={t} />;
case "vercel":
return (
<>
<VercelAccessTokenCreds t={t} />
<OpenAiExtras providerId={providerId} t={t} />
</>
);
default:
return hasWorkspaceExtra(providerId) ? (
<OpenAiExtras providerId={providerId} t={t} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const WORKSPACE_EXTRA_IDS: Record<string, true> = {
exa: true,
cosmic: true,
flora: true,
vercel: true,
};

function extraConfig(providerId: string, t: Props["t"]) {
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<section className="provider-detail-section">
<h4>{t("VercelAccessTokenTitle")}</h4>
<label className="provider-detail-field">
<span className="provider-detail-field__label">
{t("VercelAccessTokenLabel")}
</span>
<input
className="provider-detail-field__input"
type="password"
value={value}
autoComplete="off"
spellCheck={false}
placeholder={configured ? t("VercelAccessTokenConfigured") : ""}
aria-label={t("VercelAccessTokenLabel")}
onChange={(event) => setValue(event.target.value)}
/>
</label>
<div className="provider-detail-helper">{t("VercelAccessTokenHelp")}</div>
<div className="provider-detail-actions">
<button
type="button"
className="credential-btn credential-btn--primary"
disabled={busy || value.trim().length === 0}
onClick={() => void save()}
>
{t("Save")}
</button>
{configured && (
<button
type="button"
className="credential-btn credential-btn--secondary"
disabled={busy}
onClick={() => void remove()}
>
{t("Remove")}
</button>
)}
</div>
{error && <div className="provider-detail-error">{error}</div>}
</section>
);
}
Loading