diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 5c167f7a..86123b28 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -48,4 +48,8 @@ export default { { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/providers/registry/kimi.js b/open-sse/providers/registry/kimi.js index 88bfde3e..299b824f 100644 --- a/open-sse/providers/registry/kimi.js +++ b/open-sse/providers/registry/kimi.js @@ -85,5 +85,8 @@ export default { }, features: { usage: true, + // API-key connections also hit /v1/usages (x-api-key) — need usageApikey + // so isUsageEligible + /api/usage allow non-oauth authType. + usageApikey: true, }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 43bfc8ed..65819e72 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -12,6 +12,8 @@ import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getGrokCliUsage } from "./usage/grok-cli.js"; +import { getKimiUsage } from "./usage/kimi.js"; +import { getDeepseekUsage } from "./usage/deepseek.js"; import { getQwenUsage, getIflowUsage, @@ -45,6 +47,8 @@ const USAGE_HANDLERS = { "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), "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), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/deepseek.js b/open-sse/services/usage/deepseek.js new file mode 100644 index 00000000..cb70a40d --- /dev/null +++ b/open-sse/services/usage/deepseek.js @@ -0,0 +1,112 @@ +/** + * DeepSeek usage — GET https://api.deepseek.com/user/balance + * Auth: Bearer + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { toFiniteNumber } from "./shared.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function parseBalanceInfos(data) { + const list = Array.isArray(data?.balance_infos) ? data.balance_infos : []; + const results = []; + for (const item of list) { + if (!item || typeof item !== "object") continue; + const currency = + typeof item.currency === "string" ? item.currency.toUpperCase() : ""; + if (!currency) continue; + const totalBalance = toFiniteNumber( + item.total_balance ?? item.totalBalance, + 0, + ); + results.push({ + currency, + totalBalance, + grantedBalance: toFiniteNumber( + item.granted_balance ?? item.grantedBalance, + 0, + ), + toppedUpBalance: toFiniteNumber( + item.topped_up_balance ?? item.toppedUpBalance, + 0, + ), + }); + } + return results; +} + +/** + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + */ +export async function getDeepseekUsage(apiKey = null, proxyOptions = null) { + if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { + return { message: "DeepSeek API key not available. Add a key to view usage." }; + } + + try { + const response = await proxyAwareFetch( + BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey.trim()}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + if (response.status === 401 || response.status === 403) { + return { + plan: "DeepSeek", + message: "DeepSeek authentication failed. Check the API key.", + }; + } + + if (!response.ok) { + const errText = await response.text().catch(() => ""); + return { + plan: "DeepSeek", + message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`, + }; + } + + const data = await response.json().catch(() => null); + if (!data || typeof data !== "object") { + return { message: "DeepSeek balance response was not JSON." }; + } + + const balances = parseBalanceInfos(data); + if (balances.length === 0) { + return { + plan: "DeepSeek", + message: "DeepSeek connected. No balance data returned.", + }; + } + + const isAvailable = data.is_available === true || data.isAvailable === true; + const quotas = {}; + for (const b of balances) { + const total = Math.max(0, b.totalBalance); + // Credit pot: show full remaining against current balance; never set absolute + // `remaining` — QuotaTable treats it as a 0–100 percentage. + quotas[`Balance (${b.currency})`] = { + used: 0, + total, + remainingPercentage: total > 0 ? 100 : 0, + resetAt: null, + unlimited: total > 0, + }; + } + + return { + plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)", + quotas, + }; + } catch (error) { + return { message: `DeepSeek error: ${error.message}` }; + } +} diff --git a/open-sse/services/usage/kimi.js b/open-sse/services/usage/kimi.js new file mode 100644 index 00000000..9fa92eda --- /dev/null +++ b/open-sse/services/usage/kimi.js @@ -0,0 +1,211 @@ +/** + * Kimi Coding usage — GET /v1/usages + * + * Dual auth (single provider id `kimi`): + * - apiKey present → x-api-key only (platform / coding API key) + * - else accessToken → Bearer + X-Msh-* (device-code OAuth; OmniRoute parity) + * + * Note: chat messages use combined x-api-key; /usages OAuth is Bearer. + * 403 permission_denied is NOT auth-expired — account lacks usage feature / sub. + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { parseResetTime, toFiniteNumber } from "./shared.js"; +import { buildKimiHeaders } from "../../config/appConstants.js"; + +const USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +const PLAN_LEVELS = { + LEVEL_BASIC: "Moderato", + LEVEL_INTERMEDIATE: "Allegretto", + LEVEL_ADVANCED: "Allegro", + LEVEL_STANDARD: "Vivace", +}; + +function getKimiPlanName(level) { + if (!level) return ""; + const key = String(level); + if (PLAN_LEVELS[key]) return PLAN_LEVELS[key]; + return key.replace(/^LEVEL_/, "").toLowerCase(); +} + +/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */ +export function formatKimiUsageError(status, responseText) { + let parsed = null; + try { + parsed = JSON.parse(responseText || ""); + } catch { + /* plain text */ + } + + const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null; + const debug = detail0?.debug || parsed?.debug || null; + const reason = debug?.reason || parsed?.reason || ""; + const localized = + debug?.localizedMessage?.message || + detail0?.localizedMessage?.message || + parsed?.message || + ""; + + if (status === 401) { + return "Kimi authentication expired. Please re-authorize."; + } + + // Live OAuth token without Kimi Code usage entitlement returns 403 + // REASON_FEATURE_NO_PERMISSION — not an expired session. + if ( + status === 403 && + (reason === "REASON_FEATURE_NO_PERMISSION" || + /permission_denied|do not have permission|subscribe/i.test( + `${parsed?.code || ""} ${localized} ${responseText || ""}`, + )) + ) { + return ( + localized || + "Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota." + ); + } + + const snippet = (localized || responseText || "").slice(0, 100); + return snippet + ? `Kimi Coding connected. API Error ${status}: ${snippet}` + : `Kimi Coding connected. API Error ${status}`; +} + +function makeQuota({ used, total, remaining, resetAt }) { + const safeTotal = Math.max(0, toFiniteNumber(total, 0)); + const safeUsed = Math.max(0, toFiniteNumber(used, 0)); + // Prefer provider remaining when present; never set absolute `remaining` + // on the quota object — QuotaTable treats it as a 0–100 percentage. + let remainingPct; + if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) { + remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100; + } else if (safeTotal > 0) { + remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100; + } else { + remainingPct = 0; + } + return { + used: safeUsed, + total: safeTotal, + remainingPercentage: remainingPct, + resetAt: resetAt || null, + unlimited: false, + }; +} + +/** + * @param {string|null|undefined} accessToken + * @param {string|null|undefined} apiKey + * @param {object|null} proxyOptions + * @param {object|null} providerSpecificData + */ +export async function getKimiUsage( + accessToken = null, + apiKey = null, + proxyOptions = null, + providerSpecificData = null, +) { + const useApiKey = typeof apiKey === "string" && apiKey.length > 0; + const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0; + + if (!useApiKey && !useOAuth) { + return { message: "Kimi access token or API key not available." }; + } + + const authHeaders = useApiKey + ? { "x-api-key": apiKey } + : { + Authorization: `Bearer ${accessToken}`, + ...buildKimiHeaders(providerSpecificData?.deviceId), + }; + + try { + const response = await proxyAwareFetch( + USAGE_URL, + { + method: "GET", + headers: { + ...authHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }, + }, + proxyOptions, + ); + + const responseText = await response.text().catch(() => ""); + + if (!response.ok) { + return { + plan: "Kimi Coding", + message: formatKimiUsageError(response.status, responseText), + }; + } + + let data; + try { + data = JSON.parse(responseText || "{}"); + } catch { + return { + plan: "Kimi Coding", + message: "Kimi Coding connected. Invalid JSON response from API.", + }; + } + + const quotas = {}; + const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {}; + const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0); + const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0); + const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining; + const usageRemaining = + usageRemainingRaw != null && usageRemainingRaw !== "" + ? toFiniteNumber(usageRemainingRaw, NaN) + : NaN; + const usageResetTime = + usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt; + + if (usageLimit > 0) { + quotas.Weekly = makeQuota({ + used: usageUsed, + total: usageLimit, + remaining: Number.isFinite(usageRemaining) ? usageRemaining : null, + resetAt: parseResetTime(usageResetTime), + }); + } + + const limitsArray = Array.isArray(data?.limits) ? data.limits : []; + for (const item of limitsArray) { + if (!item || typeof item !== "object") continue; + const detail = item.detail && typeof item.detail === "object" ? item.detail : {}; + const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0); + const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN); + const resetTime = detail.resetTime || detail.reset_at || detail.resetAt; + if (limit > 0) { + const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit); + quotas.Ratelimit = makeQuota({ + used: Math.max(0, limit - rem), + total: limit, + remaining: rem, + resetAt: parseResetTime(resetTime), + }); + } + } + + const membershipLevel = data?.user?.membership?.level; + const planName = getKimiPlanName(membershipLevel) || "Kimi Coding"; + + if (Object.keys(quotas).length > 0) { + return { plan: planName, quotas }; + } + + return { + plan: planName, + message: "Kimi Coding connected. Usage tracked per request.", + }; + } catch (error) { + return { + message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`, + }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 7befe9fb..b3a61c19 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -492,6 +492,36 @@ export function parseQuotaData(provider, data) { } break; + case "kimi": + // Weekly / Ratelimit from /v1/usages. Prefer remainingPercentage only. + 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, + }); + }); + } + break; + + case "deepseek": + // Credit balance — remainingPercentage only (no absolute remaining). + 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, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/tests/unit/deepseek-usage.test.js b/tests/unit/deepseek-usage.test.js new file mode 100644 index 00000000..0be26edc --- /dev/null +++ b/tests/unit/deepseek-usage.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { + USAGE_SUPPORTED_PROVIDERS, + USAGE_APIKEY_PROVIDERS, +} from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_BALANCE = { + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "12.50", + granted_balance: "2.50", + topped_up_balance: "10.00", + }, + { + currency: "CNY", + total_balance: "0.00", + granted_balance: "0.00", + topped_up_balance: "0.00", + }, + ], +}; + +describe("deepseek registry usage flags", () => { + it("is listed for apikey quota dashboard", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("deepseek"); + expect(USAGE_APIKEY_PROVIDERS).toContain("deepseek"); + }); +}); + +describe("getUsageForProvider(deepseek)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs /user/balance with Bearer apiKey", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("DeepSeek"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(BALANCE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer sk-ds-test"); + }); + + it("maps balances without absolute remaining (UI treats remaining as %)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_BALANCE)); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.quotas["Balance (USD)"]).toMatchObject({ + used: 0, + total: 12.5, + remainingPercentage: 100, + }); + expect(usage.quotas["Balance (USD)"].remaining).toBeUndefined(); + // Zero CNY still listed so user sees currency row + expect(usage.quotas["Balance (CNY)"]).toMatchObject({ + used: 0, + total: 0, + remainingPercentage: 0, + }); + }); + + it("marks plan unavailable when is_available false", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + is_available: false, + balance_infos: [ + { + currency: "USD", + total_balance: "0", + granted_balance: "0", + topped_up_balance: "0", + }, + ], + }), + ); + + const usage = await getUsageForProvider({ + provider: "deepseek", + apiKey: "sk-ds-test", + }); + + expect(usage.plan).toMatch(/insufficient|unavailable/i); + expect(usage.quotas["Balance (USD)"].remainingPercentage).toBe(0); + }); + + it("returns message on missing key / 401", async () => { + const missing = await getUsageForProvider({ provider: "deepseek" }); + expect(missing.message).toMatch(/api key/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "no" }, 401)); + const auth = await getUsageForProvider({ + provider: "deepseek", + apiKey: "bad", + }); + expect(auth.message).toMatch(/auth|key|401/i); + }); +}); + +describe("parseQuotaData(deepseek)", () => { + it("forwards remainingPercentage for balance rows", () => { + const rows = parseQuotaData("deepseek", { + plan: "DeepSeek", + quotas: { + "Balance (USD)": { + used: 0, + total: 12.5, + remainingPercentage: 100, + }, + }, + }); + expect(rows[0]).toMatchObject({ + name: "Balance (USD)", + total: 12.5, + remainingPercentage: 100, + }); + }); +}); diff --git a/tests/unit/kimi-usage.test.js b/tests/unit/kimi-usage.test.js new file mode 100644 index 00000000..52d1e911 --- /dev/null +++ b/tests/unit/kimi-usage.test.js @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { USAGE_SUPPORTED_PROVIDERS, USAGE_APIKEY_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const ACTIVE_USAGE = { + user: { + membership: { level: "LEVEL_ADVANCED" }, + }, + usage: { + limit: "100", + used: "35", + remaining: "65", + resetTime: "2026-08-01T00:00:00Z", + }, + limits: [ + { + window: { type: "rate" }, + detail: { + limit: "60", + remaining: "40", + resetTime: "2026-07-29T12:00:00Z", + }, + }, + ], +}; + +describe("kimi registry usage flags", () => { + it("exposes usage + usageApikey so OAuth and apikey cards appear on /quota", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("kimi"); + expect(USAGE_APIKEY_PROVIDERS).toContain("kimi"); + }); + + it("registers transport.usage url when present (optional)", () => { + // Provider may or may not put usage url on transport; handler has its own constant. + expect(PROVIDERS.kimi).toBeTruthy(); + }); +}); + +describe("getUsageForProvider(kimi) auth selection", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("OAuth path: Bearer + X-Msh-* (OmniRoute /usages parity; not chat x-api-key)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Allegro"); + expect(usage.quotas.Weekly).toMatchObject({ + used: 35, + total: 100, + remainingPercentage: 65, + }); + + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(KIMI_USAGE_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-abc"); + expect(opts.headers["x-api-key"]).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBe("9router"); + expect(opts.headers["X-Msh-Device-Id"]).toBe("stable-device-1"); + expect(opts.headers["X-Msh-Version"]).toBeTruthy(); + }); + + it("apikey path: x-api-key only (no Bearer / X-Msh)", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + apiKey: "sk-test-123", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas.Weekly.used).toBe(35); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-test-123"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("prefers apiKey over accessToken when both present", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + await getUsageForProvider({ + provider: "kimi", + accessToken: "tok-abc", + apiKey: "sk-prefer-me", + }); + + const [, opts] = proxyAwareFetch.mock.calls[0]; + expect(opts.headers["x-api-key"]).toBe("sk-prefer-me"); + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers["X-Msh-Platform"]).toBeUndefined(); + }); + + it("maps membership levels to plan display names", async () => { + for (const [level, plan] of [ + ["LEVEL_BASIC", "Moderato"], + ["LEVEL_INTERMEDIATE", "Allegretto"], + ["LEVEL_ADVANCED", "Allegro"], + ["LEVEL_STANDARD", "Vivace"], + ]) { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level } }, + usage: { limit: "10", used: "1", remaining: "9" }, + }), + ); + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "t", + }); + expect(usage.plan).toBe(plan); + } + }); + + it("parses Weekly + Ratelimit; does not put absolute remaining on quota rows", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(ACTIVE_USAGE)); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + // Absolute remaining would break getRemainingPercentage (treats it as 0-100 %) + expect(usage.quotas.Weekly.remaining).toBeUndefined(); + expect(usage.quotas.Weekly.remainingPercentage).toBe(65); + expect(usage.quotas.Ratelimit).toMatchObject({ + used: 20, + total: 60, + remainingPercentage: expect.closeTo(40 / 60 * 100, 5), + }); + expect(usage.quotas.Ratelimit.remaining).toBeUndefined(); + }); + + it("surfaces re-authorize message only on 401 unauthenticated", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "unauthenticated", + details: [ + { + debug: { + reason: "REASON_INVALID_AUTH_TOKEN", + localizedMessage: { message: "Invalid auth token" }, + }, + }, + ], + }, + 401, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-authorize/i); + expect(usage.message).not.toMatch(/subscribe|permission/i); + expect(usage.quotas).toBeUndefined(); + }); + + it("maps 403 REASON_FEATURE_NO_PERMISSION to subscribe message (not expired)", async () => { + // Live capture: valid OAuth JWT still returns 403 permission_denied when + // the account has no Kimi Code usage entitlement. + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse( + { + code: "permission_denied", + details: [ + { + type: "common.error.v1.ErrorDetail", + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + locale: "en-US", + message: + "You do not have permission to use this feature. Please subscribe to access.", + }, + }, + }, + ], + }, + 403, + ), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "valid-but-no-sub", + providerSpecificData: { deviceId: "stable-device-1" }, + }); + + expect(usage.message).toMatch(/permission|subscribe/i); + expect(usage.message).not.toMatch(/expired|re-authorize/i); + // Must not trip usage-route AUTH_EXPIRED_PATTERNS force-refresh loop + expect(usage.message.toLowerCase()).not.toMatch(/expired|re-authorize|unauthorized|401/); + }); + + it("formatKimiUsageError distinguishes 401 vs 403 feature gate", async () => { + const { formatKimiUsageError } = await import( + "../../open-sse/services/usage/kimi.js" + ); + expect(formatKimiUsageError(401, '{"code":"unauthenticated"}')).toMatch( + /expired|re-authorize/i, + ); + expect( + formatKimiUsageError( + 403, + JSON.stringify({ + code: "permission_denied", + details: [ + { + debug: { + reason: "REASON_FEATURE_NO_PERMISSION", + localizedMessage: { + message: "You do not have permission to use this feature.", + }, + }, + }, + ], + }), + ), + ).toMatch(/permission|subscribe/i); + }); + + it("returns tracked-per-request message when usage limit missing", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + user: { membership: { level: "LEVEL_BASIC" } }, + usage: {}, + }), + ); + + const usage = await getUsageForProvider({ + provider: "kimi", + accessToken: "tok", + }); + + expect(usage.plan).toBe("Moderato"); + expect(usage.message).toMatch(/tracked per request/i); + }); + + it("returns missing-credentials message when neither token nor key", async () => { + const usage = await getUsageForProvider({ provider: "kimi" }); + expect(usage.message).toMatch(/token|key|credential/i); + expect(proxyAwareFetch).not.toHaveBeenCalled(); + }); +}); + +describe("parseQuotaData(kimi)", () => { + it("forwards remainingPercentage for dashboard bars", () => { + const rows = parseQuotaData("kimi", { + plan: "Allegro", + quotas: { + Weekly: { + used: 35, + total: 100, + remainingPercentage: 65, + resetAt: "2026-08-01T00:00:00.000Z", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "Weekly", + used: 35, + total: 100, + remainingPercentage: 65, + }); + }); +}); diff --git a/tests/unit/usage-dispatch.test.js b/tests/unit/usage-dispatch.test.js index 75096d78..21078e39 100644 --- a/tests/unit/usage-dispatch.test.js +++ b/tests/unit/usage-dispatch.test.js @@ -15,7 +15,8 @@ const load = () => import("../../open-sse/services/usage.js"); const SUPPORTED = [ "github", "gemini-cli", "antigravity", "claude", "codex", "kiro", "qoder", "qwen", "iflow", "ollama", "glm", "glm-cn", - "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", + "minimax", "minimax-cn", "vercel-ai-gateway", "grok-cli", "kimi", + "deepseek", ]; describe("usage dispatch", () => {