diff --git a/open-sse/providers/registry/codebuddy-cn.js b/open-sse/providers/registry/codebuddy-cn.js index 57d3c090..01a78a30 100644 --- a/open-sse/providers/registry/codebuddy-cn.js +++ b/open-sse/providers/registry/codebuddy-cn.js @@ -16,6 +16,8 @@ export default { }, }, category: "oauth", + authModes: ["oauth", "apikey"], + hasOAuth: true, transport: { baseUrl: "https://copilot.tencent.com/v2/chat/completions", forceStream: true, @@ -36,6 +38,11 @@ export default { header: "Authorization", scheme: "bearer", }, + // Quota endpoint differs from the chat gateway: POST returns nested Tencent + // billing payload (data.Response.Data.Accounts[]). See services/usage/codebuddy-cn.js. + usage: { + url: "https://copilot.tencent.com/v2/billing/meter/get-user-resource", + }, }, models: [ { id: "glm-5.2", name: "GLM-5.2" }, @@ -63,4 +70,8 @@ export default { platform: "CLI", pollInterval: 5000, }, + features: { + usage: true, + usageApikey: true, + }, }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index d8816709..a4cf972a 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -10,6 +10,7 @@ import { getCodexUsage, consumeCodexRateLimitResetCredit } from "./usage/codex.j export { consumeCodexRateLimitResetCredit }; import { getKiroUsage } from "./usage/kiro.js"; import { getMiniMaxUsage } from "./usage/minimax.js"; +import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js"; import { getQwenUsage, getIflowUsage, @@ -41,6 +42,7 @@ const USAGE_HANDLERS = { minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "minimax-cn": (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions), "vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions), + "codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions), }; export async function getUsageForProvider(connection, proxyOptions = null) { diff --git a/open-sse/services/usage/codebuddy-cn.js b/open-sse/services/usage/codebuddy-cn.js new file mode 100644 index 00000000..32d15c2c --- /dev/null +++ b/open-sse/services/usage/codebuddy-cn.js @@ -0,0 +1,131 @@ +/** + * CodeBuddy CN usage handler + * + * Scoped to the "codebuddy-cn" provider specifically — a future "codebuddy-intl" + * variant would get its own handler/endpoint, so keep this CN-only. + * + * Quota lives behind a Tencent billing endpoint (POST, payload wrapped twice + * under data.Response.Data). It mixes two credit types that must NOT be merged: + * + * - Refill / base ("基础体验包"): a recurring allowance whose cycle resets long + * before the resource itself expires (CycleEndTime << DeductionEndTime). The + * live numbers live in the *Cycle* fields (e.g. CycleCapacityUsed 6.54 / 500) + * and resetAt is the next monthly refresh. + * - Bonus ("活动赠送包"): one-shot credits that run a single cycle and then + * expire for good (CycleEndTime == DeductionEndTime). Numbers live in the + * plain Capacity fields. + * + * We surface one quota row per package — a cadence label (Monthly/Weekly/Daily) + * for refill packs, "Bonus Pack N" for bonus packs (soonest-expiring first). + */ + +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { PROVIDERS } from "../../providers/index.js"; +import { U, parseResetTime } from "./shared.js"; + +const PROVIDER_ID = "codebuddy-cn"; + +// Prefer the *Precise string fields (exact), fall back to the numeric ones. +function num(precise, plain) { + const n = Number(precise ?? plain); + return Number.isFinite(n) ? n : 0; +} + +// Label a refill pack by its cycle length (Monthly is the common CodeBuddy case). +function refillCadence(acc) { + const start = parseResetTime(acc.CycleStartTime); + const end = parseResetTime(acc.CycleEndTime); + if (start && end) { + const days = (new Date(end).getTime() - new Date(start).getTime()) / 86400000; + if (days <= 1.5) return "Daily"; + if (days <= 10) return "Weekly"; + } + return "Monthly"; +} + +export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) { + const token = accessToken || apiKey; + if (!token) { + return { message: "CodeBuddy CN credential not available." }; + } + + try { + const response = await proxyAwareFetch(U(PROVIDER_ID).url, { + method: "POST", + headers: { + ...(PROVIDERS[PROVIDER_ID]?.headers || {}), + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: "{}", + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "CodeBuddy CN credential invalid or expired." }; + } + if (!response.ok) { + return { message: `CodeBuddy CN quota API error (${response.status}).` }; + } + + const json = await response.json(); + if (json?.code !== 0) { + return { message: `CodeBuddy CN quota error: ${json?.msg || "unknown"}` }; + } + + const data = json?.data?.Response?.Data || {}; + const accounts = Array.isArray(data.Accounts) ? data.Accounts : []; + if (accounts.length === 0) { + return { message: "CodeBuddy CN connected. No credit package found." }; + } + + const cycleEndMs = (acc) => { + const r = parseResetTime(acc.CycleEndTime); + return r ? new Date(r).getTime() : Number.POSITIVE_INFINITY; + }; + // Refill packs roll into a new cycle before the resource expires; bonus packs + // end exactly at expiry. >2d gap between cycle end and validity end = refill. + const REFILL_GAP_MS = 2 * 24 * 60 * 60 * 1000; + const isRefill = (acc) => { + const ce = cycleEndMs(acc); + const de = Number(acc.DeductionEndTime); + return Number.isFinite(ce) && Number.isFinite(de) && de - ce > REFILL_GAP_MS; + }; + const byExpiry = (a, b) => cycleEndMs(a) - cycleEndMs(b); + + const refills = accounts.filter(isRefill).sort(byExpiry); + const bonuses = accounts.filter((a) => !isRefill(a)).sort(byExpiry); + + const quotas = {}; + // Refill packs first: cadence-labelled, using the *Cycle* balance and + // resetting at the next refresh. + const seenRefill = {}; + refills.forEach((acc) => { + const base = refillCadence(acc); + seenRefill[base] = (seenRefill[base] || 0) + 1; + const name = seenRefill[base] > 1 ? `${base} ${seenRefill[base]}` : base; + quotas[name] = { + used: num(acc.CycleCapacityUsedPrecise, acc.CycleCapacityUsed), + total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + // Bonus packs: use the lifetime Capacity balance; resetAt is the expiry. + bonuses.forEach((acc, i) => { + quotas[`Bonus Pack ${i + 1}`] = { + used: num(acc.CapacityUsedPrecise, acc.CapacityUsed), + total: num(acc.CapacitySizePrecise, acc.CapacitySize), + resetAt: parseResetTime(acc.CycleEndTime), + unlimited: false, + }; + }); + + const basePkg = refills[0] || accounts[0] || {}; + const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN"; + + return { plan, quotas }; + } catch (error) { + return { message: `CodeBuddy CN error: ${error.message}` }; + } +} diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index f289cd25..1a664f4a 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -102,8 +102,12 @@ export async function POST(request) { // Validation const isWebCookieProvider = !!WEB_COOKIE_PROVIDERS[provider]; + // Dual-auth providers (e.g. codebuddy-cn, xai) live under category "oauth" but also + // accept an API key via authModes — they aren't in APIKEY_PROVIDERS, so allow them here. + const supportsApiKeyMode = !!AI_PROVIDERS[provider]?.authModes?.includes("apikey"); const isValidProvider = APIKEY_PROVIDERS[provider] || FREE_TIER_PROVIDERS[provider] || + supportsApiKeyMode || isWebCookieProvider || isOpenAICompatibleProvider(provider) || isAnthropicCompatibleProvider(provider) ||