From 4cfe7c80a317f3ed041f9bdd03ef305894c2e1cd Mon Sep 17 00:00:00 2001 From: Lam Nguyen <46023302+bowber@users.noreply.github.com> Date: Tue, 19 May 2026 21:29:36 +0700 Subject: [PATCH] feat(quota): add Wafer.ai quota provider (#1312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(quota): add Wafer.ai quota provider - New provider: wafer.js fetches from https://pass.wafer.ai/v1/inference/quota - Auth: reads wafer/wafer-ai/wafer_ai keys from auth file - Timeout: AbortSignal.timeout(15_000) with timeoutSignal.aborted detection - Response: parses remaining/limit/overage/usedPercent/window_end/plan_tier - valueLabel: planTier + remaining/limit + overage suffix - Window: 5h (18000s) via resolveWindowLabel - Cross-runtime: added to web registry, UI types, VS Code dispatcher * fix(quota): wafer provider fixes — auth alias, decompression, logo - Add 'wafer.ai' auth alias to match actual auth key format - Use 'Accept-Encoding: identity' header to fix Bun fetch decompression issue with Cloudflare-backed responses - Match copilot valueLabel format: 'planTier · X / Y left' - Add wafer logo alias so Providers and Usage pages resolve to the same wafer.ai logo from models.dev * style(quota): fix indentation of timeoutSignal declaration * fix(quota): derive window duration from API instead of hardcoding Compute windowSeconds from window_end - window_start timestamps, with WAFER_WINDOW_SECONDS (5h) as fallback if timestamps are missing. --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/hooks/useProviderLogo.ts | 2 + packages/ui/src/lib/quota/providers/index.ts | 1 + packages/ui/src/types/quota.ts | 3 +- packages/vscode/src/quotaProviders.ts | 128 ++++++++++++++++- .../web/server/lib/quota/DOCUMENTATION.md | 1 + packages/web/server/lib/quota/index.js | 3 +- .../web/server/lib/quota/providers/index.js | 8 ++ .../web/server/lib/quota/providers/wafer.js | 131 ++++++++++++++++++ 8 files changed, 274 insertions(+), 3 deletions(-) create mode 100644 packages/web/server/lib/quota/providers/wafer.js diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 9057ba6e..0efbc136 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -23,6 +23,8 @@ const LOGO_ALIAS = new Map([ ['evroc-ai', 'evroc'], ['evrocai', 'evroc'], ['ollama-cloud', 'ollama'], + ['wafer-ai', 'wafer.ai'], + ['wafer', 'wafer.ai'], ]); const normalizeProviderId = (providerId: string | null | undefined) => { diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index e191950f..29654100 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -18,6 +18,7 @@ export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'minimax-cn-coding-plan', name: 'MiniMax Coding Plan (minimaxi.com)' }, { id: 'minimax-coding-plan', name: 'MiniMax Coding Plan (minimax.io)' }, { id: 'ollama-cloud', name: 'Ollama Cloud' }, + { id: 'wafer', name: 'Wafer.ai' }, ]; export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce< diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index 362d37b5..06348dba 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -12,7 +12,8 @@ export type QuotaProviderId = | 'zhipuai-coding-plan' | 'minimax-coding-plan' | 'minimax-cn-coding-plan' - | 'ollama-cloud'; + | 'ollama-cloud' + | 'wafer'; export interface UsageWindow { usedPercent: number | null; diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 81e730fd..d1735f78 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -97,10 +97,19 @@ type ZhipuaiMcpTimeLimit = { type ZhipuaiPayload = { data?: { limits?: Array; - level?: string; }; }; +type WaferPayload = { + remaining_included_requests?: number | string; + included_request_limit?: number | string; + overage_request_count?: number | string; + current_period_used_percent?: number | string; + window_start?: number | string; + window_end?: number | string; + plan_tier?: string; +}; + export type ProviderResult = { providerId: string; providerName: string; @@ -439,6 +448,11 @@ export const listConfiguredQuotaProviders = () => { configured.add('ollama-cloud'); } + const waferAuth = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'])); + if (waferAuth && ((waferAuth as Record).key || (waferAuth as Record).token)) { + configured.add('wafer'); + } + return Array.from(configured); }; @@ -1738,6 +1752,116 @@ export const fetchNanoGptQuota = async (): Promise => { } }; +const WAFER_QUOTA_URL = 'https://pass.wafer.ai/v1/inference/quota'; +const WAFER_WINDOW_SECONDS = 5 * 3600; + +export const fetchWaferQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['wafer', 'wafer-ai', 'wafer_ai', 'wafer.ai'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'wafer', + providerName: 'Wafer.ai', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(WAFER_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity', + }, + signal: timeoutSignal, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'wafer', + providerName: 'Wafer.ai', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as WaferPayload; + const remaining = toNumber(payload?.remaining_included_requests); + const limit = toNumber(payload?.included_request_limit); + const overage = toNumber(payload?.overage_request_count); + const usedPercentRaw = toNumber(payload?.current_period_used_percent); + const windowStart = toTimestamp(payload?.window_start); + const windowEnd = toTimestamp(payload?.window_end); + const planTier = asNonEmptyString(payload?.plan_tier); + + if (remaining === null && limit === null && overage === null && usedPercentRaw === null) { + return buildResult({ + providerId: 'wafer', + providerName: 'Wafer.ai', + ok: false, + configured: true, + error: 'No quota data in response', + }); + } + + const hasOverage = overage !== null && overage > 0; + const usedPercent = hasOverage + ? Math.max(0, usedPercentRaw ?? 0) + : Math.max(0, Math.min(100, usedPercentRaw ?? 0)); + + const windowSeconds = windowStart !== null && windowEnd !== null + ? Math.round((windowEnd - windowStart) / 1000) + : WAFER_WINDOW_SECONDS; + const windowLabel = resolveWindowLabel(windowSeconds); + + let valueLabel: string | null = null; + if (remaining !== null && limit !== null) { + const parts: string[] = []; + if (planTier) parts.push(planTier); + parts.push(`${remaining} / ${limit} left`); + if (hasOverage) parts.push(`+${overage} overage`); + valueLabel = parts.join(' · '); + } + + const windows: Record = {}; + windows[windowLabel] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt: windowEnd, + valueLabel, + }); + + return buildResult({ + providerId: 'wafer', + providerName: 'Wafer.ai', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId: 'wafer', + providerName: 'Wafer.ai', + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed'), + }); + } +}; + export const fetchQuotaForProvider = async (providerId: string): Promise => { switch (providerId) { case 'claude': @@ -1766,6 +1890,8 @@ export const fetchQuotaForProvider = async (providerId: string): Promise { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + return Boolean(entry?.key || entry?.token); +}; + +export const fetchQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: false, + error: 'Not configured' + }); + } + + const timeoutSignal = AbortSignal.timeout(15_000); + + try { + const response = await fetch(WAFER_QUOTA_URL, { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Accept-Encoding': 'identity' + }, + signal: timeoutSignal + }); + + if (!response.ok) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const remaining = toNumber(payload?.remaining_included_requests); + const limit = toNumber(payload?.included_request_limit); + const overage = toNumber(payload?.overage_request_count); + const usedPercentRaw = toNumber(payload?.current_period_used_percent); + const windowStart = toTimestamp(payload?.window_start); + const windowEnd = toTimestamp(payload?.window_end); + const planTier = asNonEmptyString(payload?.plan_tier); + + if (remaining === null && limit === null && overage === null && usedPercentRaw === null) { + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: 'No quota data in response' + }); + } + + const hasOverage = overage !== null && overage > 0; + const usedPercent = hasOverage + ? Math.max(0, usedPercentRaw ?? 0) + : Math.max(0, Math.min(100, usedPercentRaw ?? 0)); + + const windowSeconds = windowStart !== null && windowEnd !== null + ? Math.round((windowEnd - windowStart) / 1000) + : WAFER_WINDOW_SECONDS; + const windowLabel = resolveWindowLabel(windowSeconds); + + let valueLabel = null; + if (remaining !== null && limit !== null) { + const parts = []; + if (planTier) parts.push(planTier); + parts.push(`${remaining} / ${limit} left`); + if (hasOverage) parts.push(`+${overage} overage`); + valueLabel = parts.join(' · '); + } + + const windows = {}; + windows[windowLabel] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt: windowEnd, + valueLabel + }); + + return buildResult({ + providerId, + providerName, + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + const isTimeout = error instanceof DOMException && error.name === 'AbortError' && timeoutSignal.aborted; + const isParseError = error instanceof SyntaxError; + return buildResult({ + providerId, + providerName, + ok: false, + configured: true, + error: isTimeout + ? 'Request timed out' + : isParseError + ? 'Invalid response from provider' + : (error instanceof Error ? error.message : 'Request failed') + }); + } +};