mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
Update jsconfig.json and package.json to correct open-sse path references from relative to local directory.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { COOLDOWN_MS, BACKOFF_CONFIG } from "../config/constants.js";
|
||||
|
||||
/**
|
||||
* Calculate exponential backoff cooldown for rate limits (429)
|
||||
* Level 0: 1s, Level 1: 2s, Level 2: 4s... → max 30 min
|
||||
* @param {number} backoffLevel - Current backoff level
|
||||
* @returns {number} Cooldown in milliseconds
|
||||
*/
|
||||
export function getQuotaCooldown(backoffLevel = 0) {
|
||||
const cooldown = BACKOFF_CONFIG.base * Math.pow(2, backoffLevel);
|
||||
return Math.min(cooldown, BACKOFF_CONFIG.max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error should trigger account fallback (switch to next account)
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string} errorText - Error message text
|
||||
* @param {number} backoffLevel - Current backoff level for exponential backoff
|
||||
* @returns {{ shouldFallback: boolean, cooldownMs: number, newBackoffLevel?: number }}
|
||||
*/
|
||||
export function checkFallbackError(status, errorText, backoffLevel = 0) {
|
||||
// Check error message FIRST - specific patterns take priority over status codes
|
||||
if (errorText) {
|
||||
const lowerError = errorText.toLowerCase();
|
||||
|
||||
// "Request not allowed" - short cooldown (5s), takes priority over status code
|
||||
if (lowerError.includes("request not allowed")) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.requestNotAllowed };
|
||||
}
|
||||
|
||||
// Rate limit keywords - exponential backoff
|
||||
if (
|
||||
lowerError.includes("rate limit") ||
|
||||
lowerError.includes("too many requests") ||
|
||||
lowerError.includes("quota exceeded") ||
|
||||
lowerError.includes("capacity") ||
|
||||
lowerError.includes("overloaded")
|
||||
) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: getQuotaCooldown(backoffLevel),
|
||||
newBackoffLevel: newLevel
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 401 - Authentication error (token expired/invalid)
|
||||
if (status === 401) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.unauthorized };
|
||||
}
|
||||
|
||||
// 402/403 - Payment required / Forbidden (quota/permission)
|
||||
if (status === 402 || status === 403) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired };
|
||||
}
|
||||
|
||||
// 404 - Model not found (long cooldown)
|
||||
if (status === 404) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.notFound };
|
||||
}
|
||||
|
||||
// 429 - Rate limit with exponential backoff
|
||||
if (status === 429) {
|
||||
const newLevel = Math.min(backoffLevel + 1, BACKOFF_CONFIG.maxLevel);
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: getQuotaCooldown(backoffLevel),
|
||||
newBackoffLevel: newLevel
|
||||
};
|
||||
}
|
||||
|
||||
// 408/500/502/503/504 - Transient errors (short cooldown)
|
||||
if (status === 408 || status === 500 || status === 502 || status === 503 || status === 504) {
|
||||
return { shouldFallback: true, cooldownMs: COOLDOWN_MS.transient };
|
||||
}
|
||||
|
||||
return { shouldFallback: false, cooldownMs: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account is currently unavailable (cooldown not expired)
|
||||
*/
|
||||
export function isAccountUnavailable(unavailableUntil) {
|
||||
if (!unavailableUntil) return false;
|
||||
return new Date(unavailableUntil).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate unavailable until timestamp
|
||||
*/
|
||||
export function getUnavailableUntil(cooldownMs) {
|
||||
return new Date(Date.now() + cooldownMs).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter available accounts (not in cooldown)
|
||||
*/
|
||||
export function filterAvailableAccounts(accounts, excludeId = null) {
|
||||
const now = Date.now();
|
||||
return accounts.filter(acc => {
|
||||
if (excludeId && acc.id === excludeId) return false;
|
||||
if (acc.rateLimitedUntil) {
|
||||
const until = new Date(acc.rateLimitedUntil).getTime();
|
||||
if (until > now) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset account state when request succeeds
|
||||
* Clears cooldown and resets backoff level to 0
|
||||
* @param {object} account - Account object
|
||||
* @returns {object} Updated account with reset state
|
||||
*/
|
||||
export function resetAccountState(account) {
|
||||
if (!account) return account;
|
||||
return {
|
||||
...account,
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: 0,
|
||||
lastError: null,
|
||||
status: "active"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply error state to account
|
||||
* @param {object} account - Account object
|
||||
* @param {number} status - HTTP status code
|
||||
* @param {string} errorText - Error message
|
||||
* @returns {object} Updated account with error state
|
||||
*/
|
||||
export function applyErrorState(account, status, errorText) {
|
||||
if (!account) return account;
|
||||
|
||||
const backoffLevel = account.backoffLevel || 0;
|
||||
const { cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel);
|
||||
|
||||
return {
|
||||
...account,
|
||||
rateLimitedUntil: cooldownMs > 0 ? getUnavailableUntil(cooldownMs) : null,
|
||||
backoffLevel: newBackoffLevel ?? backoffLevel,
|
||||
lastError: { status, message: errorText, timestamp: new Date().toISOString() },
|
||||
status: "error"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get combo models from combos data
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @param {Array|Object} combosData - Array of combos or object with combos
|
||||
* @returns {string[]|null} Array of models or null if not a combo
|
||||
*/
|
||||
export function getComboModelsFromData(modelStr, combosData) {
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []);
|
||||
|
||||
const combo = combos.find(c => c.name === modelStr);
|
||||
if (combo && combo.models && combo.models.length > 0) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - Request body
|
||||
* @param {string[]} options.models - Array of model strings to try
|
||||
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
||||
* @param {Object} options.log - Logger object
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log }) {
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const modelStr = models[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
||||
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Success (2xx) - return response
|
||||
if (result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 401 unauthorized - return immediately (auth error)
|
||||
if (result.status === 401) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4xx/5xx - try next model
|
||||
lastError = `${modelStr}: ${result.statusText || result.status}`;
|
||||
log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status });
|
||||
}
|
||||
|
||||
log.warn("COMBO", "All models failed");
|
||||
|
||||
// Return 503 with last error
|
||||
return new Response(
|
||||
JSON.stringify({ error: lastError || "All combo models unavailable" }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Shared combo (model combo) handling with fallback support
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get combo models from combos data
|
||||
* @param {string} modelStr - Model string to check
|
||||
* @param {Array|Object} combosData - Array of combos or object with combos
|
||||
* @returns {string[]|null} Array of models or null if not a combo
|
||||
*/
|
||||
export function getComboModelsFromData(modelStr, combosData) {
|
||||
// Don't check if it's in provider/model format
|
||||
if (modelStr.includes("/")) return null;
|
||||
|
||||
// Handle both array and object formats
|
||||
const combos = Array.isArray(combosData) ? combosData : (combosData?.combos || []);
|
||||
|
||||
const combo = combos.find(c => c.name === modelStr);
|
||||
if (combo && combo.models && combo.models.length > 0) {
|
||||
return combo.models;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle combo chat with fallback
|
||||
* @param {Object} options
|
||||
* @param {Object} options.body - Request body
|
||||
* @param {string[]} options.models - Array of model strings to try
|
||||
* @param {Function} options.handleSingleModel - Function to handle single model: (body, modelStr) => Promise<Response>
|
||||
* @param {Object} options.log - Logger object
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
export async function handleComboChat({ body, models, handleSingleModel, log }) {
|
||||
let lastError = null;
|
||||
|
||||
for (let i = 0; i < models.length; i++) {
|
||||
const modelStr = models[i];
|
||||
log.info("COMBO", `Trying model ${i + 1}/${models.length}: ${modelStr}`);
|
||||
|
||||
const result = await handleSingleModel(body, modelStr);
|
||||
|
||||
// Success or client error - return response
|
||||
if (result.ok || result.status < 500) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 5xx error - try next model
|
||||
lastError = `${modelStr}: ${result.statusText || result.status}`;
|
||||
log.warn("COMBO", `Model failed, trying next`, { model: modelStr, status: result.status });
|
||||
}
|
||||
|
||||
log.warn("COMBO", "All models failed");
|
||||
|
||||
// Return 503 with last error
|
||||
return new Response(
|
||||
JSON.stringify({ error: lastError || "All combo models unavailable" }),
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Provider alias to ID mapping
|
||||
const ALIAS_TO_PROVIDER_ID = {
|
||||
cc: "claude",
|
||||
cx: "codex",
|
||||
gc: "gemini-cli",
|
||||
qw: "qwen",
|
||||
if: "iflow",
|
||||
ag: "antigravity",
|
||||
gh: "github",
|
||||
// API Key providers (alias = id)
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
gemini: "gemini",
|
||||
openrouter: "openrouter",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve provider alias to provider ID
|
||||
*/
|
||||
export function resolveProviderAlias(aliasOrId) {
|
||||
return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse model string: "alias/model" or "provider/model" or just alias
|
||||
*/
|
||||
export function parseModel(modelStr) {
|
||||
if (!modelStr) {
|
||||
return { provider: null, model: null, isAlias: false, providerAlias: null };
|
||||
}
|
||||
|
||||
// Check if standard format: provider/model or alias/model
|
||||
if (modelStr.includes("/")) {
|
||||
const firstSlash = modelStr.indexOf("/");
|
||||
const providerOrAlias = modelStr.slice(0, firstSlash);
|
||||
const model = modelStr.slice(firstSlash + 1);
|
||||
const provider = resolveProviderAlias(providerOrAlias);
|
||||
return { provider, model, isAlias: false, providerAlias: providerOrAlias };
|
||||
}
|
||||
|
||||
// Alias format (model alias, not provider alias)
|
||||
return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve model alias from aliases object
|
||||
* Format: { "alias": "provider/model" }
|
||||
*/
|
||||
export function resolveModelAliasFromMap(alias, aliases) {
|
||||
if (!aliases) return null;
|
||||
|
||||
// Check if alias exists
|
||||
const resolved = aliases[alias];
|
||||
if (!resolved) return null;
|
||||
|
||||
// Resolved value is "provider/model" format
|
||||
if (typeof resolved === "string" && resolved.includes("/")) {
|
||||
const firstSlash = resolved.indexOf("/");
|
||||
const providerOrAlias = resolved.slice(0, firstSlash);
|
||||
return {
|
||||
provider: resolveProviderAlias(providerOrAlias),
|
||||
model: resolved.slice(firstSlash + 1)
|
||||
};
|
||||
}
|
||||
|
||||
// Or object { provider, model }
|
||||
if (typeof resolved === "object" && resolved.provider && resolved.model) {
|
||||
return {
|
||||
provider: resolveProviderAlias(resolved.provider),
|
||||
model: resolved.model
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full model info (parse or resolve)
|
||||
* @param {string} modelStr - Model string
|
||||
* @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases
|
||||
*/
|
||||
export async function getModelInfoCore(modelStr, aliasesOrGetter) {
|
||||
const parsed = parseModel(modelStr);
|
||||
|
||||
if (!parsed.isAlias) {
|
||||
return {
|
||||
provider: parsed.provider,
|
||||
model: parsed.model
|
||||
};
|
||||
}
|
||||
|
||||
// Get aliases (from object or function)
|
||||
const aliases = typeof aliasesOrGetter === "function"
|
||||
? await aliasesOrGetter()
|
||||
: aliasesOrGetter;
|
||||
|
||||
// Resolve alias
|
||||
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Fallback: treat as openai model
|
||||
return {
|
||||
provider: "openai",
|
||||
model: parsed.model
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { PROVIDERS } from "../config/constants.js";
|
||||
|
||||
// Detect request format from body structure
|
||||
export function detectFormat(body) {
|
||||
// OpenAI Responses API: has input[] array instead of messages[]
|
||||
if (body.input && Array.isArray(body.input)) {
|
||||
return "openai-responses";
|
||||
}
|
||||
|
||||
// Gemini format: has contents array
|
||||
if (body.contents && Array.isArray(body.contents)) {
|
||||
return "gemini";
|
||||
}
|
||||
|
||||
// OpenAI-specific indicators (check BEFORE Claude)
|
||||
// These fields are OpenAI-specific and never appear in Claude format
|
||||
if (
|
||||
body.stream_options || // OpenAI streaming options
|
||||
body.response_format || // JSON mode, etc.
|
||||
body.logprobs !== undefined || // Log probabilities
|
||||
body.top_logprobs !== undefined ||
|
||||
body.n !== undefined || // Number of completions
|
||||
body.presence_penalty !== undefined || // Penalties
|
||||
body.frequency_penalty !== undefined ||
|
||||
body.logit_bias || // Token biasing
|
||||
body.user // User identifier
|
||||
) {
|
||||
return "openai";
|
||||
}
|
||||
|
||||
// Claude format: messages with content as array of objects with type
|
||||
// Claude requires content to be array with specific structure
|
||||
if (body.messages && Array.isArray(body.messages)) {
|
||||
const firstMsg = body.messages[0];
|
||||
|
||||
// If content is array, check if it follows Claude structure
|
||||
if (firstMsg?.content && Array.isArray(firstMsg.content)) {
|
||||
const firstContent = firstMsg.content[0];
|
||||
|
||||
// Claude format has specific types: text, image, tool_use, tool_result
|
||||
// OpenAI multimodal has: text, image_url (note the difference)
|
||||
if (firstContent?.type === "text" && !body.model?.includes("/")) {
|
||||
// Could be Claude or OpenAI multimodal
|
||||
// Check for Claude-specific fields
|
||||
if (body.system || body.anthropic_version) {
|
||||
return "claude";
|
||||
}
|
||||
// Check if image format is Claude (source.type) vs OpenAI (image_url.url)
|
||||
const hasClaudeImage = firstMsg.content.some(c =>
|
||||
c.type === "image" && c.source?.type === "base64"
|
||||
);
|
||||
const hasOpenAIImage = firstMsg.content.some(c =>
|
||||
c.type === "image_url" && c.image_url?.url
|
||||
);
|
||||
if (hasClaudeImage) return "claude";
|
||||
if (hasOpenAIImage) return "openai";
|
||||
|
||||
// If still unclear, check for tool format
|
||||
const hasClaudeTool = firstMsg.content.some(c =>
|
||||
c.type === "tool_use" || c.type === "tool_result"
|
||||
);
|
||||
if (hasClaudeTool) return "claude";
|
||||
}
|
||||
}
|
||||
|
||||
// If content is string, it's likely OpenAI (Claude also supports this)
|
||||
// Check for other Claude-specific indicators
|
||||
if (body.system !== undefined || body.anthropic_version) {
|
||||
return "claude";
|
||||
}
|
||||
}
|
||||
|
||||
// Default to OpenAI format
|
||||
return "openai";
|
||||
}
|
||||
|
||||
// Get provider config
|
||||
export function getProviderConfig(provider) {
|
||||
return PROVIDERS[provider] || PROVIDERS.openai;
|
||||
}
|
||||
|
||||
// Build provider URL
|
||||
export function buildProviderUrl(provider, model, stream = true) {
|
||||
const config = getProviderConfig(provider);
|
||||
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return `${config.baseUrl}?beta=true`;
|
||||
|
||||
case "gemini": {
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${config.baseUrl}/${model}:${action}`;
|
||||
}
|
||||
|
||||
case "gemini-cli": {
|
||||
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
|
||||
return `${config.baseUrl}:${action}`;
|
||||
}
|
||||
|
||||
case "antigravity": {
|
||||
const baseUrl = config.baseUrls[0];
|
||||
const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent";
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
case "codex":
|
||||
return config.baseUrl;
|
||||
|
||||
case "github":
|
||||
return config.baseUrl;
|
||||
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "minimax":
|
||||
// Claude-compatible providers
|
||||
return `${config.baseUrl}?beta=true`;
|
||||
|
||||
default:
|
||||
return config.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider headers
|
||||
export function buildProviderHeaders(provider, credentials, stream = true, body = null) {
|
||||
const config = getProviderConfig(provider);
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...config.headers
|
||||
};
|
||||
|
||||
// Add auth header
|
||||
switch (provider) {
|
||||
case "gemini":
|
||||
if (credentials.apiKey) {
|
||||
headers["x-goog-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "antigravity":
|
||||
case "gemini-cli":
|
||||
// Antigravity and Gemini CLI use OAuth access token
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
break;
|
||||
|
||||
case "claude":
|
||||
// Claude uses x-api-key header for API key, or Authorization for OAuth
|
||||
if (credentials.apiKey) {
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
} else if (credentials.accessToken) {
|
||||
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
|
||||
}
|
||||
break;
|
||||
|
||||
case "github":
|
||||
// GitHub Copilot requires special headers to mimic VSCode
|
||||
// Prioritize copilotToken from providerSpecificData, fallback to accessToken
|
||||
const githubToken = credentials.copilotToken || credentials.accessToken;
|
||||
// Add headers in exact same order as test endpoint
|
||||
headers["Authorization"] = `Bearer ${githubToken}`;
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["copilot-integration-id"] = "vscode-chat";
|
||||
headers["editor-version"] = "vscode/1.107.1";
|
||||
headers["editor-plugin-version"] = "copilot-chat/0.26.7";
|
||||
headers["user-agent"] = "GitHubCopilotChat/0.26.7";
|
||||
headers["openai-intent"] = "conversation-panel";
|
||||
headers["x-github-api-version"] = "2025-04-01";
|
||||
// Generate a UUID for x-request-id (Cloudflare Workers compatible)
|
||||
headers["x-request-id"] = crypto.randomUUID ? crypto.randomUUID() :
|
||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
headers["x-vscode-user-agent-library-version"] = "electron-fetch";
|
||||
headers["X-Initiator"] = "user";
|
||||
headers["Accept"] = "application/json";
|
||||
break;
|
||||
|
||||
case "codex":
|
||||
case "qwen":
|
||||
case "openai":
|
||||
case "openrouter":
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "kimi":
|
||||
case "minimax":
|
||||
// Claude-compatible API providers use x-api-key
|
||||
headers["x-api-key"] = credentials.apiKey;
|
||||
break;
|
||||
|
||||
default:
|
||||
headers["Authorization"] = `Bearer ${credentials.apiKey || credentials.accessToken}`;
|
||||
break;
|
||||
}
|
||||
|
||||
// Stream accept header
|
||||
if (stream) {
|
||||
headers["Accept"] = "text/event-stream";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Get target format for provider
|
||||
export function getTargetFormat(provider) {
|
||||
const config = getProviderConfig(provider);
|
||||
return config.format || "openai";
|
||||
}
|
||||
|
||||
// Check if last message is from user
|
||||
export function isLastMessageFromUser(body) {
|
||||
const messages = body.messages || body.contents;
|
||||
if (!messages?.length) return true;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
return lastMsg?.role === "user";
|
||||
}
|
||||
|
||||
// Check if request has thinking config
|
||||
export function hasThinkingConfig(body) {
|
||||
return !!(body.reasoning_effort || body.thinking?.type === "enabled");
|
||||
}
|
||||
|
||||
// Normalize thinking config based on last message role
|
||||
// - If lastMessage is not user → remove thinking config
|
||||
// - If lastMessage is user AND has thinking config → keep it (force enable)
|
||||
export function normalizeThinkingConfig(body) {
|
||||
if (!isLastMessageFromUser(body)) {
|
||||
delete body.reasoning_effort;
|
||||
delete body.thinking;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
|
||||
|
||||
// Token expiry buffer (refresh if expires within 5 minutes)
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Refresh OAuth access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
if (!config || !config.refreshUrl) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Claude OAuth tokens
|
||||
*/
|
||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.claude.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||||
*/
|
||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Qwen OAuth tokens
|
||||
*/
|
||||
export async function refreshQwenToken(refreshToken, log) {
|
||||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.qwen.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
||||
*/
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
scope: "openid profile email offline_access",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for iFlow OAuth tokens
|
||||
*/
|
||||
export async function refreshIflowToken(refreshToken, log) {
|
||||
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.iflow.clientId,
|
||||
client_secret: PROVIDERS.iflow.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for GitHub Copilot OAuth tokens
|
||||
*/
|
||||
export async function refreshGitHubToken(refreshToken, log) {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.github.clientId,
|
||||
client_secret: PROVIDERS.github.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token using GitHub access token
|
||||
*/
|
||||
export async function refreshCopilotToken(githubAccessToken, log) {
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${githubAccessToken}`,
|
||||
"User-Agent": "GitHub-Copilot/1.0",
|
||||
"Accept": "*/*"
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||||
hasToken: !!data.token,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
expiresAt: data.expires_at
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider
|
||||
*/
|
||||
export async function getAccessToken(provider, credentials, log) {
|
||||
if (!credentials || !credentials.refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "gemini":
|
||||
case "gemini-cli":
|
||||
case "antigravity":
|
||||
return await refreshGoogleToken(
|
||||
credentials.refreshToken,
|
||||
PROVIDERS[provider].clientId,
|
||||
PROVIDERS[provider].clientSecret,
|
||||
log
|
||||
);
|
||||
|
||||
case "claude":
|
||||
return await refreshClaudeOAuthToken(credentials.refreshToken, log);
|
||||
|
||||
case "codex":
|
||||
return await refreshCodexToken(credentials.refreshToken, log);
|
||||
|
||||
case "qwen":
|
||||
return await refreshQwenToken(credentials.refreshToken, log);
|
||||
|
||||
case "iflow":
|
||||
return await refreshIflowToken(credentials.refreshToken, log);
|
||||
|
||||
case "github":
|
||||
return await refreshGitHubToken(credentials.refreshToken, log);
|
||||
|
||||
default:
|
||||
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token by provider type (helper for handlers)
|
||||
*/
|
||||
export async function refreshTokenByProvider(provider, credentials, log) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
|
||||
switch (provider) {
|
||||
case "gemini-cli":
|
||||
case "antigravity":
|
||||
return refreshGoogleToken(
|
||||
credentials.refreshToken,
|
||||
PROVIDERS[provider].clientId,
|
||||
PROVIDERS[provider].clientSecret,
|
||||
log
|
||||
);
|
||||
case "claude":
|
||||
return refreshClaudeOAuthToken(credentials.refreshToken, log);
|
||||
case "codex":
|
||||
return refreshCodexToken(credentials.refreshToken, log);
|
||||
case "qwen":
|
||||
return refreshQwenToken(credentials.refreshToken, log);
|
||||
case "iflow":
|
||||
return refreshIflowToken(credentials.refreshToken, log);
|
||||
case "github":
|
||||
return refreshGitHubToken(credentials.refreshToken, log);
|
||||
default:
|
||||
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credentials for provider
|
||||
*/
|
||||
export function formatProviderCredentials(provider, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
if (!config) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case "gemini":
|
||||
return {
|
||||
apiKey: credentials.apiKey,
|
||||
accessToken: credentials.accessToken,
|
||||
projectId: credentials.projectId
|
||||
};
|
||||
|
||||
case "claude":
|
||||
return {
|
||||
apiKey: credentials.apiKey,
|
||||
accessToken: credentials.accessToken
|
||||
};
|
||||
|
||||
case "codex":
|
||||
case "qwen":
|
||||
case "iflow":
|
||||
case "openai":
|
||||
case "openrouter":
|
||||
return {
|
||||
apiKey: credentials.apiKey,
|
||||
accessToken: credentials.accessToken
|
||||
};
|
||||
|
||||
case "antigravity":
|
||||
case "gemini-cli":
|
||||
return {
|
||||
accessToken: credentials.accessToken,
|
||||
refreshToken: credentials.refreshToken
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
apiKey: credentials.apiKey,
|
||||
accessToken: credentials.accessToken,
|
||||
refreshToken: credentials.refreshToken
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all access tokens for a user
|
||||
*/
|
||||
export async function getAllAccessTokens(userInfo, log) {
|
||||
const results = {};
|
||||
|
||||
if (userInfo.connections && Array.isArray(userInfo.connections)) {
|
||||
for (const connection of userInfo.connections) {
|
||||
if (connection.isActive && connection.provider) {
|
||||
const token = await getAccessToken(connection.provider, {
|
||||
refreshToken: connection.refreshToken
|
||||
}, log);
|
||||
|
||||
if (token) {
|
||||
results[connection.provider] = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry and exponential backoff
|
||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||
* @param {function} refreshFn - Async function that returns token or null
|
||||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||||
* @param {object} log - Logger instance (optional)
|
||||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||||
*/
|
||||
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
const delay = attempt * 1000;
|
||||
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await refreshFn();
|
||||
if (result) return result;
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Usage Fetcher - Get usage data from provider APIs
|
||||
*/
|
||||
|
||||
// GitHub API config
|
||||
const GITHUB_CONFIG = {
|
||||
apiVersion: "2022-11-28",
|
||||
userAgent: "GitHubCopilotChat/0.26.7",
|
||||
};
|
||||
|
||||
// Antigravity API config (from Quotio)
|
||||
const ANTIGRAVITY_CONFIG = {
|
||||
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
||||
loadProjectApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com",
|
||||
clientSecret: "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",
|
||||
userAgent: "antigravity/1.11.3 Darwin/arm64",
|
||||
};
|
||||
|
||||
// Codex (OpenAI) API config
|
||||
const CODEX_CONFIG = {
|
||||
usageUrl: "https://chatgpt.com/backend-api/wham/usage",
|
||||
};
|
||||
|
||||
// Claude API config
|
||||
const CLAUDE_CONFIG = {
|
||||
usageUrl: "https://api.anthropic.com/v1/organizations/{org_id}/usage",
|
||||
settingsUrl: "https://api.anthropic.com/v1/settings",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 "gemini-cli":
|
||||
return await getGeminiUsage(accessToken);
|
||||
case "antigravity":
|
||||
return await getAntigravityUsage(accessToken);
|
||||
case "claude":
|
||||
return await getClaudeUsage(accessToken);
|
||||
case "codex":
|
||||
return await getCodexUsage(accessToken);
|
||||
case "qwen":
|
||||
return await getQwenUsage(accessToken, providerSpecificData);
|
||||
case "iflow":
|
||||
return await getIflowUsage(accessToken);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Copilot Usage
|
||||
*/
|
||||
async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI Usage (Google Cloud)
|
||||
*/
|
||||
async function getGeminiUsage(accessToken) {
|
||||
try {
|
||||
// Gemini CLI uses Google Cloud quotas
|
||||
// Try to get quota info from Cloud Resource Manager
|
||||
const response = await fetch(
|
||||
"https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Quota API may not be accessible, return generic message
|
||||
return { message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details." };
|
||||
}
|
||||
|
||||
return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity Usage - Fetch quota from Google Cloud Code API
|
||||
*/
|
||||
async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
// First get project ID from subscription info
|
||||
const projectId = await getAntigravityProjectId(accessToken);
|
||||
|
||||
// Fetch quota data
|
||||
const response = await fetch(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
return { message: "Antigravity access forbidden. Check subscription." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Antigravity API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quotas = {};
|
||||
|
||||
// Parse model quotas
|
||||
if (data.models) {
|
||||
for (const [name, info] of Object.entries(data.models)) {
|
||||
// Only include gemini and claude models
|
||||
if (!name.includes("gemini") && !name.includes("claude")) continue;
|
||||
|
||||
if (info.quotaInfo) {
|
||||
const percentage = (info.quotaInfo.remainingFraction || 0) * 100;
|
||||
quotas[name] = {
|
||||
remaining: percentage,
|
||||
resetTime: info.quotaInfo.resetTime || "",
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription info for plan type
|
||||
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken);
|
||||
|
||||
return {
|
||||
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||
quotas,
|
||||
subscriptionInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Antigravity error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity project ID from subscription info
|
||||
*/
|
||||
async function getAntigravityProjectId(accessToken) {
|
||||
try {
|
||||
const info = await getAntigravitySubscriptionInfo(accessToken);
|
||||
return info?.cloudaicompanionProject || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity subscription info
|
||||
*/
|
||||
async function getAntigravitySubscriptionInfo(accessToken) {
|
||||
try {
|
||||
const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } }),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Usage - Try to fetch from Anthropic API
|
||||
*/
|
||||
async function getClaudeUsage(accessToken) {
|
||||
try {
|
||||
// Try to get organization/account settings first
|
||||
const settingsResponse = await fetch("https://api.anthropic.com/v1/settings", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
});
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
const settings = await settingsResponse.json();
|
||||
|
||||
// Try usage endpoint if we have org info
|
||||
if (settings.organization_id) {
|
||||
const usageResponse = await fetch(
|
||||
`https://api.anthropic.com/v1/organizations/${settings.organization_id}/usage`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (usageResponse.ok) {
|
||||
const usage = await usageResponse.json();
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
quotas: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
message: "Claude connected. Usage details require admin access.",
|
||||
};
|
||||
}
|
||||
|
||||
// If settings API fails, OAuth token may not have required scope
|
||||
return { message: "Claude connected. Usage API requires admin permissions." };
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex (OpenAI) Usage - Fetch from ChatGPT backend API
|
||||
*/
|
||||
async function getCodexUsage(accessToken) {
|
||||
try {
|
||||
const response = await fetch(CODEX_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Codex API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Parse rate limit info
|
||||
const rateLimit = data.rate_limit || {};
|
||||
const primaryWindow = rateLimit.primary_window || {};
|
||||
const secondaryWindow = rateLimit.secondary_window || {};
|
||||
|
||||
// Calculate reset dates
|
||||
const sessionResetAt = primaryWindow.reset_at
|
||||
? new Date(primaryWindow.reset_at * 1000).toISOString()
|
||||
: null;
|
||||
const weeklyResetAt = secondaryWindow.reset_at
|
||||
? new Date(secondaryWindow.reset_at * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
return {
|
||||
plan: data.plan_type || "unknown",
|
||||
limitReached: rateLimit.limit_reached || false,
|
||||
quotas: {
|
||||
session: {
|
||||
used: primaryWindow.used_percent || 0,
|
||||
total: 100,
|
||||
remaining: 100 - (primaryWindow.used_percent || 0),
|
||||
resetTime: sessionResetAt,
|
||||
unlimited: false,
|
||||
},
|
||||
weekly: {
|
||||
used: secondaryWindow.used_percent || 0,
|
||||
total: 100,
|
||||
remaining: 100 - (secondaryWindow.used_percent || 0),
|
||||
resetTime: weeklyResetAt,
|
||||
unlimited: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iFlow Usage
|
||||
*/
|
||||
async function getIflowUsage(accessToken) {
|
||||
try {
|
||||
// iFlow may have usage endpoint
|
||||
return { message: "iFlow connected. Usage tracked per request." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch iFlow usage." };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user