Merge remote-tracking branch 'origin/master' into gitea/new_feature
# Conflicts: # open-sse/executors/qoder.js # open-sse/handlers/chatCore.js # open-sse/handlers/chatCore/sseToJsonHandler.js # open-sse/providers/registry/commandcode.js # src/app/(dashboard)/dashboard/combos/page.js # src/app/api/v1/models/route.js # src/lib/db/repos/usageRepo.js # src/shared/components/UsageStats.js
This commit is contained in:
@@ -12,19 +12,20 @@ import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
|
||||
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||
const HARD_CAPS = new Set(CAPABILITY_KEYS);
|
||||
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free";
|
||||
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.6-flash-free";
|
||||
const upgradeLegacyModel = (m) => (m === "oc/mimo-v2.5-free" ? DEFAULT_FALLBACK_MODEL : m);
|
||||
|
||||
// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat:
|
||||
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
|
||||
function normalizeCapEntry(entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => upgradeLegacyModel(e?.model || e)).filter(Boolean) };
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
return {
|
||||
enabled: entry.enabled !== false,
|
||||
roundRobin: !!entry.roundRobin,
|
||||
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
|
||||
models: Array.isArray(entry.models) ? entry.models.map(upgradeLegacyModel).filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
return { enabled: false, roundRobin: false, models: [] };
|
||||
|
||||
@@ -13,9 +13,13 @@
|
||||
*
|
||||
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
|
||||
* requests directly, so we exchange it for a short-lived job token (jt-...)
|
||||
* via openapi.qoder.sh/api/v1/jobToken/exchange (plain JSON POST), then use
|
||||
* that job token for signing. Job-token traffic must hit api2.qoder.sh —
|
||||
* api3 rejects jt- with "Login expired" (403).
|
||||
* via the region's jobToken/exchange endpoint (plain JSON POST), then use
|
||||
* that job token for signing. On intl, job-token traffic must hit api2.qoder.sh —
|
||||
* api3 rejects jt- with "Login expired" (403); CN serves it from the same
|
||||
* gateway host.
|
||||
*
|
||||
* The region (intl/cn) is derived from credentials.provider (or an explicit
|
||||
* options.region override) so the same catalog logic works for both sites.
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
@@ -23,12 +27,12 @@ import { createHash } from "crypto";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
|
||||
import {
|
||||
QODER_MODEL_LIST_URL,
|
||||
QODER_CHAT_BASE_ALT,
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
QODER_USERINFO_URL,
|
||||
QODER_IDE_VERSION,
|
||||
QODER_CLIENT_TYPE,
|
||||
qoderRegionOf,
|
||||
qoderJobTokenExchangeUrl,
|
||||
qoderUserInfoUrl,
|
||||
qoderInferenceBase,
|
||||
} from "../shared/qoder/constants.js";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
@@ -63,9 +67,9 @@ const inflight = new Map();
|
||||
* Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
|
||||
* This endpoint is plain JSON POST — NOT COSY-signed.
|
||||
*/
|
||||
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
|
||||
async function exchangeJobToken(pat, proxyOptions = null, signal = null, region = "intl") {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
qoderJobTokenExchangeUrl(region),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -101,10 +105,10 @@ async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
|
||||
* Resolve the Qoder userId for a job token (needed for COSY signing).
|
||||
* Returns "" on any failure — callers fall back to the stored userId.
|
||||
*/
|
||||
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
|
||||
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null, region = "intl") {
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_USERINFO_URL,
|
||||
qoderUserInfoUrl(region),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
@@ -125,16 +129,17 @@ async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = nu
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PAT to a job-token credential, cached per-PAT.
|
||||
* Resolve a PAT to a job-token credential, cached per-PAT-per-region.
|
||||
*/
|
||||
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
||||
const cached = patJobCache.get(pat);
|
||||
async function resolvePatCredential(pat, proxyOptions = null, signal = null, region = "intl") {
|
||||
const cacheKey = `${region}:${pat}`;
|
||||
const cached = patJobCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
|
||||
|
||||
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
|
||||
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
|
||||
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal, region);
|
||||
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal, region);
|
||||
const resolved = { accessToken: jobToken, userId, expiresAt };
|
||||
patJobCache.set(pat, resolved);
|
||||
patJobCache.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -142,11 +147,14 @@ async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
||||
* Resolve connection credentials to COSY-signable form:
|
||||
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
|
||||
* - everything else → passed through unchanged
|
||||
*
|
||||
* Region defaults to the one implied by credentials.provider (qoder-cn → cn).
|
||||
*/
|
||||
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) {
|
||||
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null, region) {
|
||||
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||
if (isQoderPat(raw)) {
|
||||
const resolved = await resolvePatCredential(raw, proxyOptions, signal);
|
||||
const effRegion = region || qoderRegionOf(credentials?.provider);
|
||||
const resolved = await resolvePatCredential(raw, proxyOptions, signal, effRegion);
|
||||
return {
|
||||
...credentials,
|
||||
accessToken: resolved.accessToken,
|
||||
@@ -163,13 +171,14 @@ export async function resolveQoderCredentials(credentials, proxyOptions = null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key per credential (so different login sessions for the same
|
||||
* account share an entry).
|
||||
* Stable cache key per credential+region (so different login sessions for the
|
||||
* same account share an entry, and the same PAT on both sites stays apart).
|
||||
*/
|
||||
function cacheKey(credentials) {
|
||||
const psd = credentials?.providerSpecificData || {};
|
||||
const seed = psd.userId || credentials?.refreshToken || credentials?.accessToken || "anonymous";
|
||||
return createHash("sha256").update(`qoder:${seed}`).digest("hex");
|
||||
const region = qoderRegionOf(credentials?.provider);
|
||||
return createHash("sha256").update(`qoder:${region}:${seed}`).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -192,15 +201,13 @@ function cosyCredsFromConnection(credentials) {
|
||||
* rawConfigs: Map<modelKey, modelConfigObject> }
|
||||
* or `null` on any error.
|
||||
*/
|
||||
async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) {
|
||||
async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null, region = "intl") {
|
||||
const creds = cosyCredsFromConnection(credentials);
|
||||
if (!creds.userId || !creds.authToken) return null;
|
||||
|
||||
// Job-token traffic is rejected by api3 ("Login expired" 403) — the
|
||||
// official qodercli serves it from api2 instead.
|
||||
const modelListUrl = String(creds.authToken).startsWith("jt-")
|
||||
? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list`
|
||||
: QODER_MODEL_LIST_URL;
|
||||
// Intl job-token traffic is rejected by api3 ("Login expired" 403) — the
|
||||
// official qodercli serves it from api2 instead; CN uses the single gateway.
|
||||
const modelListUrl = `${qoderInferenceBase(credentials, region)}/algo/api/v2/model/list`;
|
||||
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
@@ -293,14 +300,20 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) {
|
||||
* one upstream request per credential.
|
||||
*/
|
||||
export async function resolveQoderModels(credentials, options = {}) {
|
||||
const region = options.region || qoderRegionOf(credentials?.provider);
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal);
|
||||
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal, region);
|
||||
} catch (error) {
|
||||
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null;
|
||||
// Stamp the provider so cacheKey/catalog derive the region even when the
|
||||
// caller's credentials object didn't carry a provider id (e.g. /v1/models).
|
||||
if (resolved && !resolved.provider) {
|
||||
resolved.provider = region === "cn" ? "qoder-cn" : "qoder";
|
||||
}
|
||||
|
||||
const key = cacheKey(resolved);
|
||||
const now = Date.now();
|
||||
@@ -319,7 +332,7 @@ export async function resolveQoderModels(credentials, options = {}) {
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions);
|
||||
const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions, region);
|
||||
if (!fetched) return null;
|
||||
const entry = {
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getKimiUsage } from "./usage/kimi.js";
|
||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||
import { getCommandCodeUsage } from "./usage/commandcode.js";
|
||||
import { getOpenCodeGoUsage } from "./usage/opencode-go.js";
|
||||
import { getOpenCodeZenUsage } from "./usage/opencode-zen.js";
|
||||
import { getGroqUsage } from "./usage/groq.js";
|
||||
import { getZedUsage } from "./usage/zed.js";
|
||||
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js";
|
||||
@@ -43,12 +44,8 @@ const USAGE_HANDLERS = {
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }),
|
||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
qoder: async (c) => {
|
||||
// PAT (pt-...) connections must be exchanged to a job token before the
|
||||
// quota endpoint accepts them.
|
||||
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
||||
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
|
||||
},
|
||||
qoder: (c) => getQoderUsageFor(c),
|
||||
"qoder-cn": (c) => getQoderUsageFor(c),
|
||||
iflow: (c) => getIflowUsage(c.accessToken),
|
||||
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||
@@ -62,6 +59,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),
|
||||
"opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions),
|
||||
"opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions),
|
||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
|
||||
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
|
||||
@@ -70,6 +68,14 @@ const USAGE_HANDLERS = {
|
||||
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
|
||||
};
|
||||
|
||||
// Qoder intl/CN share one usage path: PATs must be exchanged to a job token
|
||||
// before the quota endpoint accepts them, and the quota URL comes from the
|
||||
// provider's own registry usage block (region-correct via c.provider).
|
||||
async function getQoderUsageFor(c) {
|
||||
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
||||
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions, c.provider || "qoder");
|
||||
}
|
||||
|
||||
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {
|
||||
const { provider, accessToken, apiKey, providerSpecificData, projectId } = connection;
|
||||
const providerDataWithProjectId = {
|
||||
|
||||
@@ -25,10 +25,18 @@ export function _clearWeeklyCache() {
|
||||
weeklyCache.clear();
|
||||
}
|
||||
|
||||
// — Group-name to stable key mapping ——————————————————————
|
||||
const GROUP_MATCHERS = [
|
||||
{ pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" },
|
||||
{ pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
|
||||
// — Group-name and window to stable key mapping ——————————————————————
|
||||
const GROUP_CONFIGS = [
|
||||
{
|
||||
pattern: /gemini/i,
|
||||
weekly: { key: "gemini_weekly", displayName: "Gemini (Weekly)" },
|
||||
session: { key: "gemini_session", displayName: "Gemini (5h)" },
|
||||
},
|
||||
{
|
||||
pattern: /claude|gpt/i,
|
||||
weekly: { key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
|
||||
session: { key: "claude_gpt_session", displayName: "Claude & GPT (5h)" },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -60,32 +68,40 @@ export function parseWeeklyQuotaSummary(data) {
|
||||
for (const bucket of buckets) {
|
||||
if (!bucket || typeof bucket !== "object") continue;
|
||||
|
||||
// Identify weekly buckets by checking bucketId + displayName for "weekly"
|
||||
const windowType = String(bucket.window || "").toLowerCase();
|
||||
const bucketText = `${bucket.bucketId || ""} ${bucket.displayName || ""}`.toLowerCase();
|
||||
if (!bucketText.includes("weekly")) continue;
|
||||
const isWeekly = windowType === "weekly" || bucketText.includes("weekly");
|
||||
const isSession = windowType === "5h" || bucketText.includes("five hour") || bucketText.includes("5h") || bucketText.includes("daily") || windowType === "daily";
|
||||
|
||||
// Skip disabled buckets
|
||||
if (bucket.disabled === true) continue;
|
||||
if (!isWeekly && !isSession) continue;
|
||||
|
||||
const remainingFraction = Number(bucket.remainingFraction);
|
||||
// If a session (5h) bucket is marked disabled by upstream (because weekly was hit),
|
||||
// keep it so the UI shows the 5h row, but with remainingFraction: 0.
|
||||
// Disabled weekly buckets are truly disabled and skipped.
|
||||
if (bucket.disabled === true && isWeekly) continue;
|
||||
|
||||
const remainingFraction = bucket.disabled === true ? 0 : Number(bucket.remainingFraction);
|
||||
if (!Number.isFinite(remainingFraction)) continue;
|
||||
|
||||
// Match group to a known family
|
||||
for (const matcher of GROUP_MATCHERS) {
|
||||
if (matcher.pattern.test(displayName)) {
|
||||
for (const config of GROUP_CONFIGS) {
|
||||
if (config.pattern.test(displayName)) {
|
||||
const target = isWeekly ? config.weekly : config.session;
|
||||
if (result[target.key]) break; // first matching bucket per type wins
|
||||
|
||||
const total = 1000;
|
||||
const remaining = Math.round(total * remainingFraction);
|
||||
const used = Math.max(0, total - remaining);
|
||||
|
||||
result[matcher.key] = {
|
||||
result[target.key] = {
|
||||
used,
|
||||
total,
|
||||
resetAt: parseResetTime(bucket.resetTime),
|
||||
remainingPercentage: remainingFraction * 100,
|
||||
unlimited: false,
|
||||
displayName: matcher.displayName,
|
||||
displayName: target.displayName,
|
||||
};
|
||||
break; // first matching bucket per family wins
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,39 +228,37 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
proxyOptions
|
||||
);
|
||||
|
||||
// Reconcile weekly quota against model family status:
|
||||
// Reconcile short-window session quota if models are exhausted:
|
||||
// If every model in a family is locked/exhausted (remainingPercentage === 0)
|
||||
// until a future reset time, the weekly limit cannot be 100% available.
|
||||
// On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports
|
||||
// remainingFraction: 1 even after the starter quota is depleted and all models 429.
|
||||
// until a future reset time, update the 5h session row (not the weekly row).
|
||||
const entries = Object.entries(quotas);
|
||||
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
||||
|
||||
if (weeklyQuotas.gemini_weekly && geminiModels.length > 0) {
|
||||
if (weeklyQuotas.gemini_session && geminiModels.length > 0) {
|
||||
const allGeminiExhausted = geminiModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
|
||||
if (allGeminiExhausted && weeklyQuotas.gemini_weekly.remainingPercentage > 0) {
|
||||
if (allGeminiExhausted && weeklyQuotas.gemini_session.remainingPercentage > 0) {
|
||||
const maxResetAt = geminiModels.reduce((max, [, q]) =>
|
||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||
);
|
||||
weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total;
|
||||
weeklyQuotas.gemini_weekly.remainingPercentage = 0;
|
||||
weeklyQuotas.gemini_session.used = weeklyQuotas.gemini_session.total;
|
||||
weeklyQuotas.gemini_session.remainingPercentage = 0;
|
||||
if (maxResetAt) {
|
||||
weeklyQuotas.gemini_weekly.resetAt = maxResetAt;
|
||||
weeklyQuotas.gemini_session.resetAt = maxResetAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (weeklyQuotas.claude_gpt_weekly && claudeModels.length > 0) {
|
||||
if (weeklyQuotas.claude_gpt_session && claudeModels.length > 0) {
|
||||
const allClaudeExhausted = claudeModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
|
||||
if (allClaudeExhausted && weeklyQuotas.claude_gpt_weekly.remainingPercentage > 0) {
|
||||
if (allClaudeExhausted && weeklyQuotas.claude_gpt_session.remainingPercentage > 0) {
|
||||
const maxResetAt = claudeModels.reduce((max, [, q]) =>
|
||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||
);
|
||||
weeklyQuotas.claude_gpt_weekly.used = weeklyQuotas.claude_gpt_weekly.total;
|
||||
weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0;
|
||||
weeklyQuotas.claude_gpt_session.used = weeklyQuotas.claude_gpt_session.total;
|
||||
weeklyQuotas.claude_gpt_session.remainingPercentage = 0;
|
||||
if (maxResetAt) {
|
||||
weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt;
|
||||
weeklyQuotas.claude_gpt_session.resetAt = maxResetAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,43 @@ export async function getIflowUsage(accessToken) {
|
||||
}
|
||||
}
|
||||
|
||||
const OLLAMA_LIMIT_WINDOWS = {
|
||||
session: "Session (5h)",
|
||||
weekly: "Weekly (7d)",
|
||||
monthly: "Monthly",
|
||||
};
|
||||
|
||||
function addUtcMonths(date, months) {
|
||||
const total = date.getUTCMonth() + months;
|
||||
const year = date.getUTCFullYear() + Math.floor(total / 12);
|
||||
const month = ((total % 12) + 12) % 12;
|
||||
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
return new Date(Date.UTC(
|
||||
year, month, Math.min(date.getUTCDate(), lastDay),
|
||||
date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds(),
|
||||
));
|
||||
}
|
||||
|
||||
// Free plan: "usage resets monthly from the date you signed up" (ollama.com/pricing).
|
||||
function nextMonthlyResetFromSignup(createdAt, now = new Date()) {
|
||||
const anchor = new Date(createdAt);
|
||||
if (Number.isNaN(anchor.getTime())) return null;
|
||||
const elapsedMonths = (now.getUTCFullYear() - anchor.getUTCFullYear()) * 12
|
||||
+ (now.getUTCMonth() - anchor.getUTCMonth());
|
||||
for (let i = Math.max(0, elapsedMonths); i <= elapsedMonths + 1; i++) {
|
||||
const candidate = addUtcMonths(anchor, i);
|
||||
if (candidate > now) return candidate.toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama Cloud Usage
|
||||
* GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1
|
||||
* ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed.
|
||||
* POST https://ollama.com/api/me — plan label (fail-open).
|
||||
* GET https://ollama.com/api/usage — `limits.<window>.usage` is a 0..1 ratio
|
||||
* (1.0 = limit reached). Paid plans report session (5h) + weekly (7d); the
|
||||
* free plan reports a single monthly window. No reset timestamp exposed;
|
||||
* the free monthly reset is derived from the account's signup date.
|
||||
* POST https://ollama.com/api/me — plan label + CreatedAt (fail-open).
|
||||
* Auth: Authorization: Bearer <apiKey>
|
||||
*/
|
||||
export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) {
|
||||
@@ -84,14 +116,20 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions
|
||||
return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false };
|
||||
}
|
||||
|
||||
const sessionRaw = limits.session?.usage;
|
||||
const weeklyRaw = limits.weekly?.usage;
|
||||
const sessionNum = Number(sessionRaw);
|
||||
const weeklyNum = Number(weeklyRaw);
|
||||
const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum);
|
||||
const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum);
|
||||
const monthlyResetAt = planRaw.toLowerCase() === "free" && me?.CreatedAt
|
||||
? nextMonthlyResetFromSignup(me.CreatedAt)
|
||||
: null;
|
||||
|
||||
if (!hasSession && !hasWeekly) {
|
||||
const quotas = {};
|
||||
for (const [key, label] of Object.entries(OLLAMA_LIMIT_WINDOWS)) {
|
||||
const raw = limits[key]?.usage;
|
||||
if (raw === undefined || raw === null) continue;
|
||||
const ratio = Number(raw);
|
||||
if (Number.isNaN(ratio)) continue;
|
||||
quotas[label] = ratioQuota(ratio, key === "monthly" ? monthlyResetAt : null);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return {
|
||||
plan,
|
||||
message: "Ollama Cloud connected. No usage limits reported.",
|
||||
@@ -99,10 +137,6 @@ export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions
|
||||
};
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum);
|
||||
if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum);
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `Ollama Cloud error: ${error.message}` };
|
||||
@@ -193,13 +227,13 @@ export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQoderUsage(accessToken, proxyOptions = null) {
|
||||
export async function getQoderUsage(accessToken, proxyOptions = null, providerId = "qoder") {
|
||||
if (!accessToken) {
|
||||
return { message: "Qoder usage unavailable: no access token" };
|
||||
}
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
U("qoder").url,
|
||||
U(providerId).url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
||||
107
open-sse/services/usage/opencode-zen.js
Normal file
107
open-sse/services/usage/opencode-zen.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenCode Zen usage — GET https://opencode.ai/zen/v1/usage
|
||||
* Auth: Bearer <apiKey>
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { parseResetTime, toFiniteNumber, U } from "./shared.js";
|
||||
|
||||
const USAGE_URL = U("opencode-zen").url;
|
||||
const QUOTA_NAMES = {
|
||||
rolling: "Rolling",
|
||||
weekly: "Weekly",
|
||||
monthly: "Monthly",
|
||||
};
|
||||
|
||||
function parsePercent(value) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getOpenCodeZenUsage(apiKey = null, proxyOptions = null) {
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return {
|
||||
message: "OpenCode Zen API key not available. Add a key to view usage.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
USAGE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.trim()}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
plan: "OpenCode Zen",
|
||||
message: "OpenCode Zen authentication failed. Check the API key.",
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
const error = await response.json().catch(() => null);
|
||||
const subscriptionRequired = error?.error?.type === "EntitlementError";
|
||||
return {
|
||||
plan: "OpenCode Zen",
|
||||
message: subscriptionRequired
|
||||
? "OpenCode Zen billing required for this API key."
|
||||
: "OpenCode Zen access forbidden for this API key.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
plan: "OpenCode Zen",
|
||||
message: `OpenCode Zen usage API error (${response.status}).`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data?.usage || typeof data.usage !== "object") {
|
||||
return {
|
||||
plan: "OpenCode Zen",
|
||||
message: "OpenCode Zen usage response did not contain quota data.",
|
||||
};
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
for (const [period, name] of Object.entries(QUOTA_NAMES)) {
|
||||
const quota = data.usage[period];
|
||||
if (!quota || typeof quota !== "object") continue;
|
||||
const percent = parsePercent(quota.percent);
|
||||
if (percent === null) continue;
|
||||
const used = Math.max(0, Math.min(100, toFiniteNumber(percent, 0)));
|
||||
quotas[name] = {
|
||||
used,
|
||||
total: 100,
|
||||
remaining: 100 - used,
|
||||
remainingPercentage: 100 - used,
|
||||
resetAt: parseResetTime(quota.resetsAt),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return {
|
||||
plan: "OpenCode Zen",
|
||||
message: "OpenCode Zen usage response did not contain valid quota data.",
|
||||
};
|
||||
}
|
||||
|
||||
return { plan: "OpenCode Zen", quotas };
|
||||
} catch (error) {
|
||||
return { message: `OpenCode Zen error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user