/** * Strategy registry: maps a provider ID (or its configured base URL) to the * balance/usage endpoint that can be queried with the provider's API key. * * Built-in entries cover every provider whose billing API has been verified. * `matchStrategy` also attempts URL-family matching so that custom provider * aliases (e.g. a proxied StepFun endpoint) resolve automatically. * * To add a new provider: * 1. Verify its billing endpoint (must accept Bearer API key auth). * 2. Write a parser that normalises the response into a `CurrencyResult` * or `QuotaResult`. * 3. Append to `STRATEGIES`. */ import type { BalanceQueryResult, CurrencyResult, QuotaDim, QuotaResult, } from "../types.js" import { loadCustomProviders } from "./custom-providers.js" // --------------------------------------------------------------------------- // Provider parser functions // --------------------------------------------------------------------------- function parseDeepSeek(body: unknown): CurrencyResult { const infos = (body as Record | null)?.balance_infos if (!Array.isArray(infos) || infos.length === 0) { throw new Error("unexpected deepseek balance response") } const entry = (infos as Array>).find( (i) => i?.currency === "CNY" ) ?? infos[0] const balance = Number((entry as Record)?.total_balance) if (!Number.isFinite(balance)) { throw new Error("deepseek balance is not numeric") } return { queryable: true, kind: "currency", currency: typeof (entry as Record).currency === "string" ? (entry as Record).currency as string : "CNY", balance, granted: Number((entry as Record).granted_balance) || 0, toppedUp: Number((entry as Record).topped_up_balance) || 0, available: (body as Record).is_available !== false, } } function parseStepFun(body: unknown): CurrencyResult { const b = body as Record | null const balance = Number(b?.balance) if (!Number.isFinite(balance)) { throw new Error("unexpected stepfun accounts response") } return { queryable: true, kind: "currency", currency: "CNY", balance, cash: Number(b?.total_cash_balance) || 0, voucher: Number(b?.total_voucher_balance) || 0, } } function parseKimiCoding(body: unknown): QuotaResult { // Kimi Coding `/v1/usages` returns two windows: // - `usage`: the 7-day weekly quota (membership tier) // - `limits`: a list of rate limits, the first being the 5-hour window // (window.duration=300, timeUnit=TIME_UNIT_MINUTE) const root = (body as Record | null) ?? {} const usage = root.usage as Record | undefined const limit = Number(usage?.limit) const used = Number(usage?.used) const remaining = Number(usage?.remaining) if (!Number.isFinite(limit) || !Number.isFinite(used) || !Number.isFinite(remaining)) { throw new Error("unexpected kimi usages response") } const dims: QuotaDim[] = [ { window: "weekly", limit, used, remaining, ...(typeof usage?.resetTime === "string" ? { resetTime: usage.resetTime } : {}), }, ] const limits = root.limits if (Array.isArray(limits) && limits.length > 0) { const detail = (limits[0] as Record | undefined)?.detail as | Record | undefined const hLimit = Number(detail?.limit) const hUsed = Number(detail?.used) const hRemaining = Number(detail?.remaining) if (Number.isFinite(hLimit) && Number.isFinite(hUsed) && Number.isFinite(hRemaining)) { dims.push({ window: "hourly", limit: hLimit, used: hUsed, remaining: hRemaining, ...(typeof detail?.resetTime === "string" ? { resetTime: detail.resetTime } : {}), }) } } return { queryable: true, kind: "quota", unit: "requests", limit, used, remaining, ...(typeof usage?.resetTime === "string" ? { resetTime: usage.resetTime } : {}), dims, } } function parseOpenRouter(body: unknown): CurrencyResult { const data = (body as Record | null)?.data as | Record | undefined if (!data) throw new Error("unexpected openrouter auth/key response") // OpenRouter returns limit and usage in cents const limitCents = Number(data.limit) const usageCents = Number(data.usage) if (!Number.isFinite(limitCents)) throw new Error("openrouter limit is not numeric") const balance = limitCents > 0 ? (limitCents - usageCents) / 100 : 0 return { queryable: true, kind: "currency", currency: "USD", balance, } } function parseMiniMax(body: unknown): QuotaResult { const b = body as Record | null const remaining = Number(b?.data ?? b?.remaining) const total = Number(b?.total ?? b?.limit) if (!Number.isFinite(remaining)) throw new Error("unexpected minimax remains response") return { queryable: true, kind: "quota", unit: "requests", limit: Number.isFinite(total) ? total : 0, used: Number.isFinite(total) && Number.isFinite(remaining) ? total - remaining : 0, remaining, } } function parseXai(body: unknown): CurrencyResult { const b = body as Record | null const balance = Number(b?.balance ?? b?.total_granted) if (!Number.isFinite(balance)) throw new Error("unexpected xai credit response") return { queryable: true, kind: "currency", currency: "USD", balance, } } // --------------------------------------------------------------------------- // Strategy type and registry // --------------------------------------------------------------------------- interface Strategy { readonly suffix: string readonly defaultBaseURL: string readonly defaultKeyEnv: string readonly parse: (body: unknown) => BalanceQueryResult /** Additional known provider IDs that share this strategy. */ readonly aliases?: readonly string[] } /** * Canonical strategy registry. Keys are the "primary" provider IDs. * * `matchStrategy` resolves by: * 1. Exact ID match (case-insensitive against all known IDs incl. aliases). * 2. Base-URL family match (e.g. any `*.stepfun.com/v1` → stepfun). */ export const STRATEGIES: Record = { deepseek: { suffix: "/user/balance", defaultBaseURL: "https://api.deepseek.com", defaultKeyEnv: "DEEPSEEK_API_KEY", parse: parseDeepSeek, aliases: ["deepseek-official"], }, stepfun: { suffix: "/accounts", defaultBaseURL: "https://api.stepfun.com/v1", defaultKeyEnv: "STEPFUN_API_KEY", parse: parseStepFun, }, "kimi-coding": { suffix: "/v1/usages", defaultBaseURL: "https://api.kimi.com/coding", defaultKeyEnv: "KIMI_API_KEY", parse: parseKimiCoding, }, openrouter: { suffix: "/api/v1/auth/key", defaultBaseURL: "https://openrouter.ai", defaultKeyEnv: "OPENROUTER_API_KEY", parse: parseOpenRouter, }, minimax: { suffix: "/v1/token_plan/remains", defaultBaseURL: "https://api.minimax.chat", defaultKeyEnv: "MINIMAX_API_KEY", parse: parseMiniMax, }, xai: { suffix: "/v1/dashboard/billing/credit_grants", defaultBaseURL: "https://api.x.ai", defaultKeyEnv: "XAI_API_KEY", parse: parseXai, aliases: ["xai", "grok"], }, } // Pre-computed lookup: every known ID → canonical key const KNOWN_IDS = new Map() for (const [canonical, strategy] of Object.entries(STRATEGIES)) { KNOWN_IDS.set(canonical.toLowerCase(), canonical) for (const alias of strategy.aliases ?? []) { KNOWN_IDS.set(alias.toLowerCase(), canonical) } } // URL family matchers: regex → canonical strategy key const URL_MATCHERS: Array<[RegExp, string]> = [ [/api\.deepseek\.com/i, "deepseek"], [/api\.stepfun\.com/i, "stepfun"], [/api\.kimi\.com/i, "kimi-coding"], [/openrouter\.ai/i, "openrouter"], [/api\.minimax\.chat/i, "minimax"], [/api\.x\.ai/i, "xai"], [/platform\.stepfun\.com/i, "stepfun"], ] /** * Provider-route prefixes introduced by adapter plugins that wrap an existing * provider. e.g. dsh-vision-toolkit exposes `vision-toolkit-deepseek-official` * over the underlying `deepseek-official` route. The wrapped route still bills * the UNDERLYING provider, so its balance must be read from the original * provider's endpoint — the wrapper's own baseURL/keyEnv are irrelevant here. */ const ADAPTER_PREFIXES: readonly string[] = [ "vision-toolkit-", ] /** * Providers whose balance can only be viewed through a web login (no API-key * billing endpoint). The pill renders "click to log in and view" and opens * the console URL in a new page. */ const LOGIN_REQUIRED_BY_ID: Record = { "qwen-token-plan-cn": "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal", "qwen-token-plan": "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal", "qwen-coding-plan": "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal", xiaomi: "https://platform.xiaomimimo.com/console/balance", } // baseURL family → console URL for login-required providers const LOGIN_REQUIRED_URLS: Array<[RegExp, string]> = [ [ /bailian\.console\.aliyun\.com|dashscope|aliyuncs\.com/i, "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal", ], [/xiaomimimo\.com/i, "https://platform.xiaomimimo.com/console/balance"], ] export interface ResolvedStrategy { readonly url: string readonly keyEnv: string readonly canonical: string readonly parse: (body: unknown) => BalanceQueryResult } /** * Get all strategies (built-in + custom). * Built-in strategies take precedence over config file entries. */ function getAllStrategies(): Record { const custom = loadCustomProviders() // Custom first, then built-in overrides (so built-in wins on conflicts) return { ...custom, ...STRATEGIES } } /** * Build a lookup map for all known IDs (including custom providers). */ function buildKnownIds(): Map { const map = new Map() const all = getAllStrategies() for (const [canonical, strategy] of Object.entries(all)) { map.set(canonical.toLowerCase(), canonical) for (const alias of strategy.aliases ?? []) { map.set(alias.toLowerCase(), canonical) } } return map } /** * Resolve a provider ID that is already known (canonical or alias) into a * concrete strategy. `configuredBaseURL`/`configuredKeyEnv` are overrides * from settings; pass `undefined` to use the strategy's own defaults. */ function resolveKnown( allStrategies: Record, allKnownIds: Map, providerId: string, configuredBaseURL?: string, configuredKeyEnv?: string, ): ResolvedStrategy | undefined { const canonical = allKnownIds.get(providerId.toLowerCase()) if (canonical === undefined) return undefined const s = allStrategies[canonical] if (s === undefined) return undefined return { url: `${stripSlash(configuredBaseURL ?? s.defaultBaseURL)}${s.suffix}`, keyEnv: configuredKeyEnv ?? s.defaultKeyEnv, canonical, parse: s.parse, } } /** * Resolve a provider to its balance query strategy. * * @param providerId - The provider group ID from the model directory. * @param configuredBaseURL - The baseURL from settings (if any). * @param configuredKeyEnv - The apiKeyEnv from settings (if any). * @returns The resolved strategy, or `undefined` if no match. */ export function matchStrategy( providerId: string, configuredBaseURL?: string, configuredKeyEnv?: string, ): ResolvedStrategy | undefined { const allStrategies = getAllStrategies() const allKnownIds = buildKnownIds() // 1) Exact ID match const exact = resolveKnown( allStrategies, allKnownIds, providerId, configuredBaseURL, configuredKeyEnv, ) if (exact !== undefined) return exact // 2) Adapter-prefixed ID (e.g. dsh-vision-toolkit wraps providers as // "vision-toolkit-deepseek-official"). Strip the prefix and rematch the // underlying provider. The balance endpoint lives on the ORIGINAL // provider's domain, so ignore the wrapper's baseURL/keyEnv and use the // strategy defaults. const lower = providerId.toLowerCase() for (const prefix of ADAPTER_PREFIXES) { if (!lower.startsWith(prefix)) continue const stripped = providerId.slice(prefix.length) const wrapped = resolveKnown(allStrategies, allKnownIds, stripped) if (wrapped !== undefined) return wrapped break } // 3) URL family match (for custom aliases that point to a known endpoint) const baseURL = configuredBaseURL if (baseURL !== undefined) { for (const [pattern, key] of URL_MATCHERS) { if (pattern.test(baseURL)) { const s = allStrategies[key] if (s !== undefined) { return { url: `${stripSlash(baseURL)}${s.suffix}`, keyEnv: configuredKeyEnv ?? s.defaultKeyEnv, canonical: key, parse: s.parse, } } } } } return undefined } /** * Resolve a provider to its web-login console URL, if it has no API balance * endpoint but can be viewed via login. * * @returns the console URL, or `undefined` when the provider is not * login-required (it either has an API strategy or is unknown). */ export function matchLoginRequired( providerId: string, configuredBaseURL?: string, ): string | undefined { const byId = LOGIN_REQUIRED_BY_ID[providerId.toLowerCase()] if (byId !== undefined) return byId // Adapter-prefixed wrapper (e.g. vision-toolkit-qwen-token-plan-cn) still // points at the same web console as the underlying provider. const lower = providerId.toLowerCase() for (const prefix of ADAPTER_PREFIXES) { if (lower.startsWith(prefix)) { const stripped = providerId.slice(prefix.length) const wrapped = LOGIN_REQUIRED_BY_ID[stripped.toLowerCase()] if (wrapped !== undefined) return wrapped break } } if (configuredBaseURL !== undefined) { for (const [pattern, url] of LOGIN_REQUIRED_URLS) { if (pattern.test(configuredBaseURL)) return url } } return undefined } function stripSlash(url: string): string { return url.replace(/\/+$/, "") }