diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 650624677a..9978e054ac 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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 { getIflowUsage, @@ -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 = {}) { diff --git a/open-sse/services/usage/zed.js b/open-sse/services/usage/zed.js new file mode 100644 index 0000000000..ded9c89291 --- /dev/null +++ b/open-sse/services/usage/zed.js @@ -0,0 +1,228 @@ +/** + * 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, + summarizeZedPlan, +} 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 planSummary = summarizeZedPlan(userInfo); + 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."; + } else if (planSummary?.blocksHostedModels && Object.keys(quotas).length === 0) { + message = planSummary.message; + } + + 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"}` }; + } +} diff --git a/open-sse/shared/zedAuth.js b/open-sse/shared/zedAuth.js index aa3337d786..e3d8371a70 100644 --- a/open-sse/shared/zedAuth.js +++ b/open-sse/shared/zedAuth.js @@ -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) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 9f18e5d1fe..57a7a9d248 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -150,6 +150,7 @@ export default function QuotaTable({
{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 */}
+ {!isUnlimited && (
@@ -182,16 +184,23 @@ export default function QuotaTable({ style={{ width: `${Math.min(quota.remaining, 100)}%` }} />
+ )}
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() : "∞"}`} - - {quota.remaining}% + + {isUnlimited ? "Unlimited" : `${quota.remaining}%`}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index c55633c2c9..683ea53d85 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1265,6 +1265,11 @@ export default function ProviderLimits() { onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)} /> )} + {quota?.message && !error && !isLoading && ( +

+ {quota.message} +

+ )} {hiddenQuotaRows.length > 0 && (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index ffd861e025..75d4a8fcf7 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -549,6 +549,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) { diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index e0e8084058..5b86ee9ead 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -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", () => { diff --git a/tests/unit/zed-usage.test.js b/tests/unit/zed-usage.test.js new file mode 100644 index 0000000000..4ad7421c17 --- /dev/null +++ b/tests/unit/zed-usage.test.js @@ -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, + }); + }); +});