Files
OmniRoute/src/lib/usage/fetcher.ts
Diego Rodrigues de Sa e Souza 7c23dab64d Release v3.8.40
v3.8.40 cycle integration → main. All test gates green (Unit/Integration/Coverage/Node-compat/Quality-Ratchet). The only red check, 'PR Test Policy', is the test-masking heuristic firing on the cumulative ~57-commit release diff (legitimate assert consolidations already reviewed per-PR — Gemini CLI removal #5246, retired GPT models #5280, provider catalog refreshes); overridden with --admin per the documented release-PR convention. CodeQL/SonarQube advisory scans non-blocking; #5278's code already passed CodeQL on main. Homologated on VPS 192.168.0.15 (v3.8.40 healthy).
2026-06-29 08:40:06 -03:00

484 lines
15 KiB
TypeScript

/**
* Usage Fetcher - Get usage data from provider APIs
*/
import {
getGitHubCopilotInternalUserHeaders,
getKiroServiceHeaders,
} from "@omniroute/open-sse/config/providerHeaderProfiles.ts";
import { applyAntigravityClientProfileHeaders } from "@omniroute/open-sse/services/antigravityClientProfile.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import {
getAntigravityFetchAvailableModelsUrls,
ANTIGRAVITY_BASE_URLS,
} from "@omniroute/open-sse/config/antigravityUpstream.ts";
import {
getAntigravityRemainingCredits,
updateAntigravityRemainingCredits,
} from "@omniroute/open-sse/executors/antigravity.ts";
import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts";
import {
generateAntigravityRequestId,
getAntigravitySessionId,
} from "@omniroute/open-sse/services/antigravityIdentity.ts";
/**
* Get usage data for a provider connection
* @param {Object} connection - Provider connection with accessToken
* @returns {Object} Usage data with quotas
*/
export async function getUsageForProvider(connection) {
const { provider, accessToken, providerSpecificData } = connection;
switch (provider) {
case "github":
return await getGitHubUsage(accessToken, providerSpecificData);
case "antigravity":
case "agy":
return await getAntigravityUsage(
accessToken,
providerSpecificData,
connection.projectId,
connection.id
);
case "claude":
return await getClaudeUsage(accessToken);
case "codex":
return await getCodexUsage(accessToken, providerSpecificData);
case "qwen":
return await getQwenUsage(accessToken, providerSpecificData);
case "qoder":
return await getQoderUsage(accessToken);
case "kiro":
case "amazon-q":
return await getKiroUsage(accessToken);
default:
return { message: `Usage API not implemented for ${provider}` };
}
}
/**
* GitHub Copilot Usage
*/
async function getGitHubUsage(accessToken, providerSpecificData) {
try {
// Use copilotToken for copilot_internal API, not GitHub OAuth accessToken
const copilotToken = providerSpecificData?.copilotToken;
if (!copilotToken) {
throw new Error("Copilot token not found. Please refresh token first.");
}
const response = await fetch("https://api.github.com/copilot_internal/user", {
headers: getGitHubCopilotInternalUserHeaders(`Bearer ${copilotToken}`),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`GitHub API error: ${error}`);
}
const data = await response.json();
// Handle different response formats (paid vs free)
if (data.quota_snapshots) {
// Paid plan format
const snapshots = data.quota_snapshots;
return {
plan: data.copilot_plan,
resetDate: data.quota_reset_date,
quotas: {
chat: formatGitHubQuotaSnapshot(snapshots.chat),
completions: formatGitHubQuotaSnapshot(snapshots.completions),
premium_interactions: formatGitHubQuotaSnapshot(snapshots.premium_interactions),
},
};
} else if (data.monthly_quotas || data.limited_user_quotas) {
// Free/limited plan format
const monthlyQuotas = data.monthly_quotas || {};
const usedQuotas = data.limited_user_quotas || {};
return {
plan: data.copilot_plan || data.access_type_sku,
resetDate: data.limited_user_reset_date,
quotas: {
chat: {
used: usedQuotas.chat || 0,
total: monthlyQuotas.chat || 0,
unlimited: false,
},
completions: {
used: usedQuotas.completions || 0,
total: monthlyQuotas.completions || 0,
unlimited: false,
},
},
};
}
return { message: "GitHub Copilot connected. Unable to parse quota data." };
} catch (error) {
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
}
}
function formatGitHubQuotaSnapshot(quota) {
if (!quota) return { used: 0, total: 0, unlimited: true };
return {
used: quota.entitlement - quota.remaining,
total: quota.entitlement,
remaining: quota.remaining,
unlimited: quota.unlimited || false,
};
}
/**
* Proactive credit balance probe for Antigravity.
*
* Fires a minimal streamGenerateContent request with GOOGLE_ONE_AI credits enabled
* and maxOutputTokens=1 to extract the `remainingCredits` field from the SSE stream.
* This uses ~1 credit but lets us show the balance on the dashboard without waiting
* for a real user request.
*
* Returns the credit balance, or null if the probe failed.
*/
async function probeAntigravityCreditBalance(
accessToken: string,
accountId: string,
projectId?: string | null,
providerSpecificData: Record<string, unknown> = {}
): Promise<number | null> {
try {
if (!projectId) return null; // Can't call streamGenerateContent without a projectId
const baseUrl = ANTIGRAVITY_BASE_URLS[0];
const url = `${baseUrl}/v1internal:streamGenerateContent?alt=sse`;
const body = {
project: projectId,
model: "gemini-2-flash",
userAgent: "antigravity",
requestType: "agent",
requestId: generateAntigravityRequestId(),
enabledCreditTypes: ["GOOGLE_ONE_AI"],
request: {
model: "gemini-2-flash",
contents: [{ role: "user", parts: [{ text: "hi" }] }],
generationConfig: { maxOutputTokens: 1 },
sessionId: getAntigravitySessionId({ connectionId: accountId, projectId }),
},
};
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
Accept: "text/event-stream",
};
applyAntigravityClientProfileHeaders(
headers,
{ connectionId: accountId, projectId, providerSpecificData },
body
);
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) return null;
// Read the full SSE response and scan for remainingCredits
const rawSSE = await res.text();
const lines = rawSSE.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const payload = trimmed.slice(5).trim();
if (payload === "[DONE]") break;
try {
const parsed = JSON.parse(payload);
if (Array.isArray(parsed?.remainingCredits)) {
const googleCredit = parsed.remainingCredits.find(
(c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI"
);
if (googleCredit) {
const balance = parseInt(googleCredit.creditAmount, 10);
if (!isNaN(balance)) {
// Cache the balance for future reads (also persists to DB)
updateAntigravityRemainingCredits(accountId, balance);
return balance;
}
}
}
} catch {
// Skip malformed SSE lines
}
}
return null;
} catch {
// Probe is best-effort — don't let it break the usage fetch
return null;
}
}
/**
* Antigravity Usage
* Calls fetchAvailableModels to get per-model quota fractions.
* Credit balance (GOOGLE_ONE_AI) is read from the executor's in-memory cache,
* which is populated automatically after each successful credit-injected SSE call.
* If the cache is empty and credits mode is enabled, fires a minimal probe request
* to fetch the balance proactively.
*/
async function getAntigravityUsage(
accessToken: string,
providerSpecificData: Record<string, unknown> = {},
projectId?: string | null,
connectionId?: string | null
) {
try {
// Use connectionId as the cache key — matches executor's credentials.connectionId
const accountId: string = connectionId || "unknown";
// Read cached credit balance from executor module (populated from SSE remainingCredits)
let creditBalance = getAntigravityRemainingCredits(accountId);
// If no cached balance and credits mode is enabled, fire a minimal probe
const creditsMode = getCreditsMode();
if (creditBalance === null && creditsMode !== "off") {
creditBalance = await probeAntigravityCreditBalance(
accessToken,
accountId,
projectId,
providerSpecificData
);
}
// fetchAvailableModels — resolves project from token, no projectId needed
let res: Response | null = null;
let lastError: Error | null = null;
for (const endpoint of getAntigravityFetchAvailableModelsUrls()) {
try {
res = await fetch(endpoint, {
method: "POST",
headers: getAntigravityHeaders("fetchAvailableModels", accessToken),
body: JSON.stringify({}),
signal: AbortSignal.timeout(15_000),
});
if (res.ok || res.status === 401 || res.status === 403) {
break;
}
} catch (error) {
lastError = error as Error;
}
}
if (!res) {
throw lastError || new Error("Antigravity API unavailable");
}
if (!res.ok) {
return {
plan: "Antigravity",
message: "Antigravity connected. Unable to fetch model quotas.",
...(creditBalance !== null && {
quotas: {
credits: {
used: 0,
total: 0,
remaining: creditBalance,
unlimited: false,
resetAt: null,
},
},
}),
};
}
const data = await res.json();
const models: Record<string, unknown> = data?.models ?? {};
// Walk quota-based models (those with remainingFraction in quotaInfo)
let quotaModelsTotal = 0;
let quotaModelsAvailable = 0;
const modelQuotas: Record<
string,
{ remaining: number; resetAt: string | null; limited: boolean }
> = {};
for (const [modelId, rawInfo] of Object.entries(models)) {
const info = rawInfo as Record<string, unknown>;
if (info.isInternal) continue;
const quotaInfo = (info.quotaInfo as Record<string, unknown>) ?? {};
// Process models with any quota metadata (exhausted models may have only resetTime, active models have remainingFraction, credit-based models have empty quotaInfo)
if (Object.keys(quotaInfo).length > 0) {
// Default to 0 when remainingFraction is undefined/null/invalid, clamp to valid range [0, 1]
const fraction =
typeof quotaInfo.remainingFraction === "number"
? Math.max(0, Math.min(1, quotaInfo.remainingFraction))
: 0;
const resetTime = typeof quotaInfo.resetTime === "string" ? quotaInfo.resetTime : null;
modelQuotas[modelId] = {
remaining: Math.round(fraction * 100),
resetAt: resetTime,
limited: fraction <= 0,
};
quotaModelsTotal++;
if (fraction > 0) quotaModelsAvailable++;
}
// Credit-based models have empty quotaInfo — their availability is
// tracked via the GOOGLE_ONE_AI credit balance cached from SSE responses.
}
const allLimited = quotaModelsTotal > 0 && quotaModelsAvailable === 0;
return {
plan: "Antigravity",
quotas: {
models: {
used: quotaModelsTotal - quotaModelsAvailable,
total: quotaModelsTotal,
remaining: quotaModelsAvailable,
limited: allLimited,
unlimited: false,
resetAt: null,
},
...(creditBalance !== null && {
credits: {
used: 0,
total: 0,
remaining: creditBalance,
unlimited: false,
resetAt: null,
},
}),
},
modelQuotas,
limitReached: allLimited,
};
} catch (error) {
return { message: `Unable to fetch Antigravity usage: ${(error as Error).message}` };
}
}
/**
* Claude Usage (legacy fallback)
* Real Claude OAuth quota windows are fetched in @omniroute/open-sse/services/usage.ts.
*/
async function getClaudeUsage(accessToken?: string) {
try {
return {
message:
"Claude connected. Detailed quota windows are handled by the open-sse usage service.",
};
} catch (error) {
return { message: "Unable to fetch Claude usage." };
}
}
/**
* Codex (OpenAI) Usage
* Note: Actual quota tracking is handled by open-sse/services/usage.ts
* This fallback returns a message directing users to the dashboard.
*/
async function getCodexUsage(accessToken, providerSpecificData: Record<string, any> = {}) {
try {
// Check if workspace is bound
const workspaceId = providerSpecificData?.workspaceId;
if (workspaceId) {
return {
message: `Codex connected (workspace: ${workspaceId.slice(0, 8)}...). Check dashboard for quota.`,
};
}
return { message: "Codex connected. Check OpenAI dashboard for usage." };
} catch (error) {
return { message: "Unable to fetch Codex usage." };
}
}
/**
* Qwen Usage
*/
async function getQwenUsage(accessToken, providerSpecificData) {
try {
const resourceUrl = providerSpecificData?.resourceUrl;
if (!resourceUrl) {
return { message: "Qwen connected. No resource URL available." };
}
// Qwen may have usage endpoint at resource URL
return { message: "Qwen connected. Usage tracked per request." };
} catch (error) {
return { message: "Unable to fetch Qwen usage." };
}
}
/**
* Qoder Usage
*/
async function getQoderUsage(accessToken) {
try {
// Qoder may have usage endpoint
return { message: "Qoder connected. Usage tracked per request." };
} catch (error) {
return { message: "Unable to fetch Qoder usage." };
}
}
/**
* Kiro Credits
* Fetches credit balance from Kiro's AWS CodeWhisperer backend.
* The endpoint mirrors what Kiro IDE uses internally for the credit badge.
*/
async function getKiroUsage(accessToken: string) {
try {
const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/getUserCredits", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
...getKiroServiceHeaders("application/json"),
},
});
if (!response.ok) {
const errText = await response.text();
// 401/403 = expired token, show user-friendly message
if (response.status === 401 || response.status === 403) {
return { message: "Kiro token expired. Please reconnect in Dashboard → Providers → Kiro." };
}
throw new Error(`Kiro credits API error (${response.status}): ${errText}`);
}
const data = await response.json();
// Response shape: { remainingCredits, totalCredits, resetDate, subscriptionType }
const remaining = data.remainingCredits ?? data.remaining_credits ?? null;
const total = data.totalCredits ?? data.total_credits ?? null;
const resetDate = data.resetDate ?? data.reset_date ?? null;
const plan = data.subscriptionType ?? data.subscription_type ?? "unknown";
if (remaining === null) {
return { message: "Kiro connected. Credit data unavailable — check Kiro IDE for balance." };
}
return {
plan,
credits: {
remaining,
total: total ?? remaining,
used: total != null ? total - remaining : 0,
unlimited: total === null || total === 0,
resetDate,
},
};
} catch (error: any) {
return { message: `Unable to fetch Kiro credits: ${error.message}` };
}
}