feat(usage): show Zed plan quota on the dashboard

Add a Zed usage handler so connected Zed accounts appear on
/dashboard/quota. Reads GET /client/users/me for plan, edit
predictions, optional hosted model requests and billing-cycle reset.

Render unlimited rows as "N used · Unlimited" instead of 0 / ∞, and
surface overdue-invoice / token-billing messages.
This commit is contained in:
Amir Seify
2026-08-28 15:52:51 +07:00
committed by decolua
parent 67d9182e1a
commit e5a13c3ab7
8 changed files with 459 additions and 12 deletions

View File

@@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn
import { getGrokCliUsage } from "./usage/grok-cli.js";
import { getKimiUsage } from "./usage/kimi.js";
import { getDeepseekUsage } from "./usage/deepseek.js";
import { getZedUsage } from "./usage/zed.js";
import { resolveQoderCredentials } from "./qoderModels.js";
import { getGlmUsage } from "./usage/glm.js";
import {
@@ -54,6 +55,7 @@ const USAGE_HANDLERS = {
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {

View File

@@ -0,0 +1,222 @@
/**
* Zed usage — GET https://cloud.zed.dev/client/users/me
* Auth: Authorization: {user_id} {access_token}
*
* Quota rows are derived from plan.usage (edit_predictions, optional model_requests)
* and subscription_period.ended_at for billing-cycle reset.
*/
import { fetchZedAuthenticatedUser } from "../../shared/zedAuth.js";
import { parseResetTime, toFiniteNumber } from "./shared.js";
/** Map plan_v3 ids to dashboard labels (CodexBar-compatible). */
export function formatZedPlanLabel(rawPlan) {
const raw = String(rawPlan || "").trim();
if (!raw) return "Zed";
switch (raw.toLowerCase()) {
case "zed_free":
return "Zed Free";
case "zed_pro":
return "Zed Pro";
case "zed_pro_trial":
return "Zed Pro Trial";
case "zed_student":
return "Zed Student";
case "zed_business":
return "Zed Business";
default:
return raw
.replace(/_/g, " ")
.split(/\s+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
}
/**
* Parse Zed UsageLimit JSON: "unlimited", a number, or { limited: N }.
*/
export function parseZedUsageLimit(limit) {
if (limit == null) return { unlimited: false, total: 0 };
if (limit === "unlimited" || limit?.unlimited === true) {
return { unlimited: true, total: 0 };
}
if (typeof limit === "number" && Number.isFinite(limit)) {
return { unlimited: false, total: Math.max(0, limit) };
}
if (typeof limit === "string") {
const trimmed = limit.trim();
if (trimmed === "unlimited") return { unlimited: true, total: 0 };
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) return { unlimited: false, total: Math.max(0, parsed) };
}
const limited = limit.limited ?? limit.Limited;
if (typeof limited === "number" && Number.isFinite(limited)) {
return { unlimited: false, total: Math.max(0, limited) };
}
return { unlimited: false, total: 0 };
}
/** limit `{ limited: 0 }` on Pro/Student means token billing, not a 0-cap request quota. */
export function isZedTokenBillingModelRequestsLimit(limitRaw) {
const info = parseZedUsageLimit(limitRaw);
return !info.unlimited && info.total === 0;
}
function makeZedQuotaRow(name, usedRaw, limitRaw, resetAt = null) {
const used = Math.max(0, toFiniteNumber(usedRaw, 0));
const limitInfo = parseZedUsageLimit(limitRaw);
if (limitInfo.unlimited) {
return {
used,
total: 0,
remainingPercentage: 100,
resetAt: resetAt || null,
unlimited: true,
};
}
const total = limitInfo.total;
if (total <= 0) {
return {
used,
total: 0,
remainingPercentage: 0,
resetAt: resetAt || null,
unlimited: false,
};
}
const clampedUsed = Math.min(used, total);
const remaining = Math.max(0, total - clampedUsed);
return {
used: clampedUsed,
total,
remainingPercentage: (remaining / total) * 100,
resetAt: resetAt || null,
unlimited: false,
};
}
function usageBucketLimit(bucket) {
if (!bucket || typeof bucket !== "object") return null;
if (bucket.limit != null) return bucket.limit;
return bucket;
}
/**
* Map /client/users/me JSON → { plan, quotas, message } for the dashboard.
*/
export function parseZedAuthenticatedUserUsage(userInfo) {
const plan = userInfo?.plan || {};
const planId =
plan.plan_v3 || plan.plan_v2 || plan.plan || userInfo?.plan_v3 || null;
const resetAt =
parseResetTime(plan.subscription_period?.ended_at) ||
parseResetTime(plan.subscriptionPeriod?.endedAt) ||
null;
const quotas = {};
const usage = plan.usage || {};
const editPredictions = usage.edit_predictions || usage.editPredictions;
if (editPredictions) {
quotas["Edit Predictions"] = makeZedQuotaRow(
"Edit Predictions",
editPredictions.used,
editPredictions.limit,
resetAt,
);
}
const modelRequests = usage.model_requests || usage.modelRequests;
if (modelRequests) {
const limitRaw =
modelRequests.limit != null
? modelRequests.limit
: usageBucketLimit(modelRequests)?.limit;
const limitInfo = parseZedUsageLimit(limitRaw);
// Token-billed plans report model_requests.limit=0 — not a request quota.
if (limitInfo.unlimited || limitInfo.total > 0) {
quotas["Hosted Model Requests"] = makeZedQuotaRow(
"Hosted Model Requests",
modelRequests.used,
limitRaw,
resetAt,
);
}
}
const tokenBillingNote =
modelRequests &&
isZedTokenBillingModelRequestsLimit(
modelRequests.limit ?? usageBucketLimit(modelRequests)?.limit,
)
? "Hosted AI models are billed per token (not request count). Edit Predictions are tracked below. Token spend is on dashboard.zed.dev."
: null;
let planLabel = formatZedPlanLabel(planId);
if (plan.trial_started_at || plan.trialStartedAt) {
if (!/trial/i.test(planLabel)) planLabel = `${planLabel} (Trial active)`;
}
let message = tokenBillingNote;
if (plan.has_overdue_invoices || plan.hasOverdueInvoices) {
message = "This Zed account has overdue invoices. Usage may be blocked until billing is resolved.";
}
return {
plan: planLabel,
quotas,
message,
hasOverdueInvoices: !!(plan.has_overdue_invoices || plan.hasOverdueInvoices),
trialStarted: !!(plan.trial_started_at || plan.trialStartedAt),
planId: planId || null,
resetAt,
};
}
/**
* @param {string|null|undefined} accessToken
* @param {object|null|undefined} providerSpecificData
* @param {object|null|undefined} proxyOptions
*/
export async function getZedUsage(
accessToken = null,
providerSpecificData = {},
proxyOptions = null,
) {
const psd = providerSpecificData || {};
const userId = psd.userId;
if (!accessToken || typeof accessToken !== "string" || !accessToken.trim()) {
return { message: "Zed access token not available. Re-connect Zed to view quota." };
}
if (!userId) {
return { message: "Zed credential is missing user id. Re-connect Zed to view quota." };
}
const credentials = {
accessToken: accessToken.trim(),
providerSpecificData: psd,
};
try {
const userInfo = await fetchZedAuthenticatedUser(credentials, { proxyOptions });
return parseZedAuthenticatedUserUsage(userInfo);
} catch (error) {
const status = error?.status;
if (status === 401 || status === 403) {
return {
message: "Zed authentication failed. Sign in again from the dashboard or Zed editor.",
};
}
return { message: `Zed error: ${error.message || "Failed to fetch quota"}` };
}
}

View File

@@ -172,8 +172,8 @@ function getSystemId(credentials) {
);
}
async function fetchJson(url, options) {
const res = await proxyAwareFetch(url, options);
async function fetchJson(url, options, proxyOptions = null) {
const res = await proxyAwareFetch(url, options, proxyOptions);
const text = await res.text();
let data = null;
if (text) {
@@ -203,11 +203,15 @@ export async function fetchZedAuthenticatedUser(credentials, options = {}) {
const systemId = getSystemId(credentials);
if (systemId) headers[ZED_HEADERS.systemId] = systemId;
return fetchJson(zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL), {
method: "GET",
headers,
signal: options.signal ?? undefined,
});
return fetchJson(
zedUrl(config, "cloudBaseUrl", "/client/users/me", ZED_CLOUD_BASE_URL),
{
method: "GET",
headers,
signal: options.signal ?? undefined,
},
options.proxyOptions ?? null,
);
}
function normalizeOrganizationId(value) {

View File

@@ -150,6 +150,7 @@ export default function QuotaTable({
<div className="space-y-px">
{currentPageRows.map((quota) => {
const isUnlimited = quota.unlimited === true;
const colors = getColorClasses(quota.remaining);
const countdown = formatResetTime(quota.resetAt);
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
@@ -174,6 +175,7 @@ export default function QuotaTable({
{/* Progress + used/total */}
<div className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-1.5"}`}>
{!isUnlimited && (
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
}`}>
@@ -182,16 +184,23 @@ export default function QuotaTable({
style={{ width: `${Math.min(quota.remaining, 100)}%` }}
/>
</div>
)}
<div className={`flex items-center justify-between gap-1 min-w-0 ${compact ? "text-[10px]" : "text-xs"}`}>
<span
className="text-text-muted truncate"
title={`${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
title={
isUnlimited
? `${quota.used.toLocaleString()} used · Unlimited`
: `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`
}
>
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
{isUnlimited
? `${quota.used.toLocaleString()} used · Unlimited`
: `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}`}
</span>
<span className={`font-medium ${colors.text} shrink-0`}>
{quota.remaining}%
<span className={`font-medium ${isUnlimited ? "text-green-600 dark:text-green-400" : colors.text} shrink-0`}>
{isUnlimited ? "Unlimited" : `${quota.remaining}%`}
</span>
</div>
</div>

View File

@@ -1265,6 +1265,11 @@ export default function ProviderLimits() {
onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)}
/>
)}
{quota?.message && !error && !isLoading && (
<p className="mt-2 px-1 text-[10px] leading-relaxed text-text-muted">
{quota.message}
</p>
)}
{hiddenQuotaRows.length > 0 && (
<div className="mt-2 flex min-w-0 items-center gap-1 border-t border-black/5 pt-2 text-[10px] text-text-muted dark:border-white/5">
<span className="material-symbols-outlined shrink-0 text-[14px]">

View File

@@ -558,6 +558,22 @@ export function parseQuotaData(provider, data) {
}
break;
case "zed":
// Edit predictions + optional hosted model_requests; unlimited uses remainingPercentage.
if (data.quotas) {
Object.entries(data.quotas).forEach(([name, quota]) => {
normalizedQuotas.push({
name,
used: quota.used || 0,
total: quota.total || 0,
resetAt: quota.resetAt || null,
remainingPercentage: quota.remainingPercentage,
unlimited: quota.unlimited,
});
});
}
break;
default:
// Generic fallback for unknown providers
if (data.quotas) {

View File

@@ -16,7 +16,7 @@ const SUPPORTED = [
"github", "gemini-cli", "antigravity", "claude", "codex", "kiro",
"qoder", "iflow", "ollama", "glm", "glm-cn",
"minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi",
"deepseek",
"deepseek", "zed",
];
describe("usage dispatch", () => {

View File

@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../open-sse/shared/zedAuth.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
fetchZedAuthenticatedUser: vi.fn(),
};
});
import { fetchZedAuthenticatedUser } from "../../open-sse/shared/zedAuth.js";
import { getUsageForProvider } from "../../open-sse/services/usage.js";
import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js";
import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js";
import {
formatZedPlanLabel,
parseZedUsageLimit,
parseZedAuthenticatedUserUsage,
} from "../../open-sse/services/usage/zed.js";
describe("zed registry usage flags", () => {
it("is listed in USAGE_SUPPORTED_PROVIDERS", () => {
expect(USAGE_SUPPORTED_PROVIDERS).toContain("zed");
});
});
describe("parseZedUsageLimit", () => {
it("parses unlimited string and object forms", () => {
expect(parseZedUsageLimit("unlimited")).toEqual({ unlimited: true, total: 0 });
expect(parseZedUsageLimit({ unlimited: true })).toEqual({ unlimited: true, total: 0 });
});
it("parses numeric and limited object forms", () => {
expect(parseZedUsageLimit(50)).toEqual({ unlimited: false, total: 50 });
expect(parseZedUsageLimit("25")).toEqual({ unlimited: false, total: 25 });
expect(parseZedUsageLimit({ limited: 40 })).toEqual({ unlimited: false, total: 40 });
});
});
describe("formatZedPlanLabel", () => {
it("maps known plan ids", () => {
expect(formatZedPlanLabel("zed_pro")).toBe("Zed Pro");
expect(formatZedPlanLabel("zed_pro_trial")).toBe("Zed Pro Trial");
});
});
describe("parseZedAuthenticatedUserUsage", () => {
it("maps edit_predictions and billing cycle reset", () => {
const parsed = parseZedAuthenticatedUserUsage({
plan: {
plan_v3: "zed_pro",
subscription_period: {
started_at: "2026-07-01T00:00:00Z",
ended_at: "2026-08-01T00:00:00Z",
},
usage: {
edit_predictions: { used: 12, limit: 50 },
},
},
});
expect(parsed.plan).toBe("Zed Pro");
expect(parsed.quotas["Edit Predictions"]).toMatchObject({
used: 12,
total: 50,
remainingPercentage: 76,
resetAt: "2026-08-01T00:00:00.000Z",
});
});
it("marks unlimited edit predictions at 100% remaining", () => {
const parsed = parseZedAuthenticatedUserUsage({
plan: {
plan_v3: "zed_pro",
usage: {
edit_predictions: { used: 999, limit: "unlimited" },
},
},
});
expect(parsed.quotas["Edit Predictions"]).toMatchObject({
used: 999,
total: 0,
remainingPercentage: 100,
unlimited: true,
});
});
it("skips token-billed model_requests limit=0 and adds billing note", () => {
const parsed = parseZedAuthenticatedUserUsage({
plan: {
plan_v3: "zed_student",
usage: {
model_requests: { used: 0, limit: { limited: 0 } },
edit_predictions: { used: 0, limit: "unlimited" },
},
},
});
expect(parsed.quotas["Hosted Model Requests"]).toBeUndefined();
expect(parsed.quotas["Edit Predictions"]).toBeDefined();
expect(parsed.message).toMatch(/token/i);
expect(parsed.message).toMatch(/dashboard\.zed\.dev/);
});
it("surfaces overdue invoice warning", () => {
const parsed = parseZedAuthenticatedUserUsage({
plan: {
plan_v3: "zed_pro",
has_overdue_invoices: true,
usage: {
edit_predictions: { used: 0, limit: "unlimited" },
},
},
});
expect(parsed.hasOverdueInvoices).toBe(true);
expect(parsed.message).toMatch(/overdue invoices/i);
});
});
describe("getUsageForProvider(zed)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns quotas from /client/users/me", async () => {
fetchZedAuthenticatedUser.mockResolvedValueOnce({
plan: {
plan_v3: "zed_student",
usage: {
edit_predictions: { used: 3, limit: 30 },
},
},
});
const usage = await getUsageForProvider({
provider: "zed",
accessToken: "plain-token",
providerSpecificData: { userId: "user-42", systemId: "sys-1" },
});
expect(usage.plan).toBe("Zed Student");
expect(usage.quotas["Edit Predictions"]).toMatchObject({
used: 3,
total: 30,
remainingPercentage: 90,
});
expect(fetchZedAuthenticatedUser).toHaveBeenCalledWith(
{
accessToken: "plain-token",
providerSpecificData: { userId: "user-42", systemId: "sys-1" },
},
{ proxyOptions: null },
);
});
it("requires user id on the connection", async () => {
const usage = await getUsageForProvider({
provider: "zed",
accessToken: "plain-token",
providerSpecificData: {},
});
expect(usage.message).toMatch(/missing user id/i);
expect(fetchZedAuthenticatedUser).not.toHaveBeenCalled();
});
});
describe("parseQuotaData(zed)", () => {
it("normalizes zed quotas for QuotaTable", () => {
const data = parseZedAuthenticatedUserUsage({
plan: {
plan_v3: "zed_pro",
usage: { edit_predictions: { used: 10, limit: 20 } },
},
});
const rows = parseQuotaData("zed", data);
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
name: "Edit Predictions",
used: 10,
total: 20,
remainingPercentage: 50,
});
});
});