merge: resolve conflicts with origin/master - keep both local and remote features

This commit is contained in:
2026-06-22 11:40:16 +07:00
424 changed files with 24520 additions and 11731 deletions

View File

@@ -4,6 +4,82 @@
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
import { unavailableResponse } from "../utils/error.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { extractTextContent } from "../translator/formats/gemini.js";
// Hard capabilities = input modalities; missing one drops request data (e.g. image
// stripped). Must be prioritized. Soft (e.g. search) only degrades a feature.
const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]);
// Prefixes used when flattening tool turns into plain prose for panel models.
const TOOL_CALL_PREFIX = "[Called tools: ";
const TOOL_RESULT_PREFIX = "[Tool result: ";
// Flatten tool turns into prose so panel models keep the context but can't loop
// on tools: drop the request's tools, turn tool/function results into assistant
// text, and inline assistant tool_calls names instead of the structured field.
function flattenToolHistory(messages) {
return messages
.filter((msg) => msg)
.map((msg) => {
if (msg.role === "tool" || msg.role === "function") {
return { role: "assistant", content: `${TOOL_RESULT_PREFIX}${extractTextContent(msg.content) || String(msg.content ?? "")}]` };
}
if (msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
const { tool_calls, ...rest } = msg;
const names = tool_calls.map((c) => c?.function?.name || c?.name || "tool").join(", ");
const base = extractTextContent(rest.content) || (typeof rest.content === "string" ? rest.content : "");
return { ...rest, content: `${base}${base ? "\n" : ""}${TOOL_CALL_PREFIX}${names}]` };
}
if (Array.isArray(msg.content)) {
const hasToolUse = msg.content.some((c) => c.type === "tool_use");
const hasToolResult = msg.content.some((c) => c.type === "tool_result");
if (hasToolUse || hasToolResult) {
const textParts = [];
const toolNames = [];
const toolResults = [];
for (const block of msg.content) {
if (block.type === "text" && block.text) textParts.push(block.text);
if (block.type === "tool_use") toolNames.push(block.name || "tool");
if (block.type === "tool_result") toolResults.push(extractTextContent(block.content) || String(block.content ?? ""));
}
const { ...rest } = msg;
let newContent = textParts.join("\n");
if (toolNames.length > 0) {
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_CALL_PREFIX}${toolNames.join(", ")}]`;
}
if (toolResults.length > 0) {
newContent = `${newContent}${newContent ? "\n" : ""}${TOOL_RESULT_PREFIX}${toolResults.join("\n")}]`;
}
return { ...rest, content: newContent };
}
}
return msg;
});
}
// Reorder combo models by capability fit. Stable; never drops a model (fallback intact).
// Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest.
export function reorderByCapabilities(models, required) {
if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models;
const hard = [...required].filter((c) => HARD_CAPS.has(c));
const soft = [...required].filter((c) => !HARD_CAPS.has(c));
const tierOf = (m) => {
const slash = typeof m === "string" ? m.indexOf("/") : -1;
const provider = slash > 0 ? m.slice(0, slash) : "";
const model = slash > 0 ? m.slice(slash + 1) : m;
const caps = getCapabilitiesForModel(provider, model);
if (!hard.every((c) => caps[c] === true)) return 2;
return soft.every((c) => caps[c] === true) ? 0 : 1;
};
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
return models
.map((m, i) => ({ m, i, t: tierOf(m) }))
.sort((a, b) => a.t - b.t || a.i - b.i)
.map((x) => x.m);
}
/**
* Track rotation state per combo (for round-robin strategy)
@@ -11,6 +87,51 @@ import { unavailableResponse } from "../utils/error.js";
*/
const comboRotationState = new Map();
// Trailing run of items after the last assistant/model turn = the current user
// turn. It may span several messages (e.g. text + image split across blocks),
// so we return all of them. History media (older turns) must not pin the combo
// to a vision model — those get stripped + placeholdered downstream instead.
function trailingUserItems(arr) {
if (!Array.isArray(arr) || arr.length === 0) return [];
const isAssistant = (r) => r === "assistant" || r === "model";
let i = arr.length - 1;
while (i >= 0 && !isAssistant(arr[i]?.role)) i--;
return arr.slice(i + 1);
}
// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned
// only on the current user turn; "search" is request-wide (lives in tools).
// Returns a Set of: "vision" | "pdf" | "search".
export function detectRequiredCapabilities(body) {
const required = new Set();
if (!body || typeof body !== "object") return required;
const scanBlock = (b) => {
if (!b || typeof b !== "object") return;
const t = b.type;
if (t === "image_url" || t === "image" || t === "input_image") required.add("vision");
if (t === "file" || t === "document" || t === "input_file") required.add("pdf");
// gemini parts: inlineData/fileData carry a mime
const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision");
if (mime === "application/pdf") required.add("pdf");
};
const scanContent = (content) => {
if (Array.isArray(content)) for (const b of content) scanBlock(b);
};
// Modalities: current user turn only (trailing user run across each known shape).
for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude
for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses
const contents = body.contents || body.request?.contents; // gemini / antigravity
for (const c of trailingUserItems(contents)) scanContent(c.parts);
// search: temporarily disabled in auto-switch (feature not wired yet).
return required;
}
function normalizeStickyLimit(stickyLimit) {
const parsed = Number.parseInt(stickyLimit, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
@@ -105,9 +226,21 @@ export function getComboModelsFromData(modelStr, combosData) {
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
* @returns {Promise<Response>}
*/
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1 }) {
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) {
// Apply rotation strategy if enabled
const rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
// Auto-switch: float models that satisfy the request's required capabilities to the front.
if (autoSwitch) {
const required = detectRequiredCapabilities(body);
if (required.size > 0) {
const reordered = reorderByCapabilities(rotatedModels, required);
if (reordered[0] !== rotatedModels[0]) {
log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`);
}
rotatedModels = reordered;
}
}
let lastError = null;
let earliestRetryAfter = null;
@@ -196,3 +329,243 @@ export async function handleComboChat({ body, models, handleSingleModel, log, co
{ status, headers: { "Content-Type": "application/json" } }
);
}
/**
* Extract assistant text from a non-stream completion across formats
* (OpenAI chat, Claude messages, Gemini, OpenAI Responses). Returns "" if none.
* Panel responses are already translated to the client format by chatCore, so the
* leaf content→string step reuses the translator's own extractTextContent.
*/
function extractPanelText(json) {
if (!json || typeof json !== "object") return "";
// OpenAI chat completion
const choice = json.choices?.[0];
if (choice) {
const msg = choice.message ?? choice.delta ?? {};
const t = extractTextContent(msg.content);
if (t.trim()) return t;
if (typeof choice.text === "string" && choice.text.trim()) return choice.text;
}
// Claude messages (text blocks share OpenAI's {type:"text"} shape)
const claudeText = extractTextContent(json.content);
if (claudeText.trim()) return claudeText;
// Gemini (parts carry .text without a type discriminator)
const parts = json.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
const t = parts.map((p) => p?.text || "").join("");
if (t.trim()) return t;
}
// OpenAI Responses API
if (Array.isArray(json.output)) {
const t = json.output
.flatMap((o) => (Array.isArray(o.content) ? o.content.map((c) => c?.text || "") : []))
.join("");
if (t.trim()) return t;
}
return "";
}
/**
* Append a synthesized user turn to whichever message array the request format uses.
* Preserves the original conversation + system prompt so the judge has full context.
*/
function appendUserTurn(body, text) {
const next = { ...body };
if (Array.isArray(body.messages)) {
next.messages = [...body.messages, { role: "user", content: text }];
} else if (Array.isArray(body.input)) {
next.input = [...body.input, { role: "user", content: text }];
} else if (Array.isArray(body.contents)) {
next.contents = [...body.contents, { role: "user", parts: [{ text }] }];
} else {
next.messages = [{ role: "user", content: text }];
}
return next;
}
/**
* Build the judge directive. Per OpenRouter's Fusion design, the judge does NOT
* merge — it analyzes (consensus / contradictions / partial coverage / unique
* insights / blind spots) then writes one answer grounded in that analysis.
* ~3/4 of fusion's quality lift comes from this synthesis step.
*
* Sources are anonymized ("Source N") so the judge weighs substance, not the
* reputation of a model brand.
*/
function buildJudgePrompt(answers) {
const panel = answers
.map((a, i) => `[Source ${i + 1}]\n${a.text}`)
.join("\n\n");
return [
`You are the JUDGE in a model-fusion panel. ${answers.length} expert models independently answered the user's most recent request. Their responses are below, anonymized by source.`,
"",
"Do NOT mention that multiple models were used, and do NOT refer to the sources. Produce ONE authoritative final answer addressed directly to the user.",
"",
"First, internally analyze the panel along these dimensions: consensus (points most sources agree on — treat as higher-confidence), contradictions (where they disagree — resolve with your own judgment), partial coverage, unique insights only one source surfaced, and blind spots every source missed. Then write the best possible final answer grounded in that analysis — more complete and correct than any single response, with no filler.",
"",
"=== PANEL RESPONSES ===",
panel,
"=== END PANEL RESPONSES ===",
"",
"Now write the final answer to the user's original request.",
].join("\n");
}
// Fusion tuning. Overridable per-combo via settings.comboStrategies[name].
const FUSION_DEFAULTS = {
minPanel: 2, // answers needed before stragglers get a grace window
stragglerGraceMs: 8000, // wait this long for laggards once quorum is reached
panelHardTimeoutMs: 90000, // absolute cap so one hung model can't stall forever
};
// Resolve a Response (or {__error}) within ms; the loser keeps running but is ignored.
function withTimeout(promise, ms) {
return new Promise((resolve) => {
const t = setTimeout(() => resolve({ __timeout: true }), ms);
Promise.resolve(promise)
.then((v) => { clearTimeout(t); resolve(v); })
.catch((e) => { clearTimeout(t); resolve({ __error: e }); });
});
}
/**
* Collect panel responses with quorum-grace: as soon as `minPanel` calls succeed,
* start a short grace timer for the rest, then proceed with whatever arrived. This
* caps the straggler penalty (the slowest model otherwise dominates wall time) while
* still preferring a full panel when everyone is fast. Bounded by a hard timeout.
* Returns a sparse array aligned to `calls` (undefined = not yet / dropped).
*/
function collectPanel(calls, { minPanel, stragglerGraceMs, panelHardTimeoutMs }) {
return new Promise((resolve) => {
const out = new Array(calls.length);
let settled = 0;
let ok = 0;
let finished = false;
let graceTimer = null;
const finish = () => {
if (finished) return;
finished = true;
clearTimeout(hardTimer);
if (graceTimer) clearTimeout(graceTimer);
resolve(out);
};
const hardTimer = setTimeout(finish, panelHardTimeoutMs);
calls.forEach((p, i) => {
Promise.resolve(p)
.then((v) => { out[i] = v; })
.catch((e) => { out[i] = { __error: e }; })
.finally(() => {
settled++;
if (out[i] && out[i].ok) ok++;
if (settled === calls.length) return finish();
if (ok >= minPanel && !graceTimer) graceTimer = setTimeout(finish, stragglerGraceMs);
});
});
});
}
/**
* Handle a fusion combo: fan the prompt out to every panel model in parallel,
* then a judge model synthesizes one final answer from all panel responses.
*
* Panel calls are forced non-streaming with tools stripped (the judge needs
* complete prose to synthesize). The judge call keeps the client's original
* stream flag + tools, so streaming and downstream tool use still work.
*
* Speed: quorum-grace collection caps the straggler penalty. Quality: the judge
* runs the consensus/contradiction/blind-spot analysis before writing.
*
* Degrades gracefully: 0 panel answers -> 503, exactly 1 -> return it directly.
*
* @param {Object} options
* @param {Object} options.body - Request body (client format)
* @param {string[]} options.models - Panel model strings
* @param {Function} options.handleSingleModel - (body, modelStr) => Promise<Response>
* @param {Object} options.log - Logger
* @param {string} [options.comboName] - Combo name (logging)
* @param {string} [options.judgeModel] - Judge model; falls back to panel[0]
* @param {Object} [options.tuning] - Override FUSION_DEFAULTS (minPanel, grace, timeout)
* @returns {Promise<Response>}
*/
export async function handleFusionChat({ body, models, handleSingleModel, log, comboName, judgeModel, tuning }) {
const panel = Array.isArray(models) ? models.filter(Boolean) : [];
if (panel.length === 0) {
return new Response(
JSON.stringify({ error: { message: "Fusion combo has no models" } }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// A single-model fusion has nothing to fuse — just answer directly.
if (panel.length === 1) {
return handleSingleModel(body, panel[0]);
}
const cfg = { ...FUSION_DEFAULTS, ...(tuning || {}) };
const minPanel = Math.min(Math.max(2, cfg.minPanel), panel.length);
const judge = judgeModel && judgeModel.trim() ? judgeModel.trim() : panel[0];
log.info("FUSION", `Combo "${comboName}" | panel=${panel.length} [${panel.join(", ")}] | judge=${judge} | quorum=${minPanel}`);
// 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose).
const { tools, tool_choice, ...rest } = body;
const panelBody = { ...rest, stream: false };
// Flatten tool turns to prose so panel models keep context without emitting tool_calls.
if (Array.isArray(panelBody.messages)) {
panelBody.messages = flattenToolHistory(panelBody.messages);
} else if (Array.isArray(panelBody.input)) {
panelBody.input = flattenToolHistory(panelBody.input);
}
const t0 = Date.now();
const calls = panel.map((m) => withTimeout(handleSingleModel(panelBody, m, true), cfg.panelHardTimeoutMs));
const settled = await collectPanel(calls, { ...cfg, minPanel });
log.info("FUSION", `fan-out collected in ${Date.now() - t0}ms`);
// 2. Collect successful answers.
const answers = [];
for (let i = 0; i < settled.length; i++) {
const res = settled[i];
const model = panel[i];
if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); continue; }
if (res.__timeout) { log.warn("FUSION", `Panel ${model} timed out`); continue; }
if (res.__error) { log.warn("FUSION", `Panel ${model} threw`, { error: res.__error?.message || String(res.__error) }); continue; }
if (!res.ok) { log.warn("FUSION", `Panel ${model} failed`, { status: res.status }); continue; }
try {
const json = await res.clone().json();
const text = extractPanelText(json);
if (text) {
answers.push({ model, text });
log.info("FUSION", `Panel ${model} ok (${text.length} chars)`);
} else {
log.warn("FUSION", `Panel ${model} returned empty content`);
}
} catch (e) {
log.warn("FUSION", `Panel ${model} unparseable`, { error: e.message || String(e) });
}
}
// 3. Degrade gracefully when the panel is too thin to fuse.
if (answers.length === 0) {
log.warn("FUSION", "All panel models failed");
return new Response(
JSON.stringify({ error: { message: "All fusion panel models failed" } }),
{ status: 503, headers: { "Content-Type": "application/json" } }
);
}
if (answers.length === 1) {
log.info("FUSION", `Only ${answers[0].model} succeeded — answering directly (no fusion)`);
return handleSingleModel(body, answers[0].model);
}
// 4. Judge analyzes + writes one final answer (streams to client if requested).
const judgeBody = appendUserTurn(body, buildJudgePrompt(answers));
log.info("FUSION", `Judging ${answers.length} answers with ${judge}`);
return handleSingleModel(judgeBody, judge);
}

View File

@@ -1,147 +1,22 @@
// 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",
kr: "kiro",
cu: "cursor",
kc: "kilocode",
kmc: "kimi-coding",
cl: "cline",
oc: "opencode",
ocg: "opencode-go",
qd: "qoder",
qoder: "qoder",
// TTS providers
import REGISTRY from "../providers/registry/index.js";
// Alias→id derived from registry single-source: id→id, alias→id, aliases[]→id.
// Media-only providers without a registry transport entry keep explicit aliases here.
const MEDIA_ONLY_ALIASES = {
el: "elevenlabs",
// API Key providers
openai: "openai",
vercel: "vercel-ai-gateway",
"vercel-ai-gateway": "vercel-ai-gateway",
anthropic: "anthropic",
gemini: "gemini",
openrouter: "openrouter",
glm: "glm",
kimi: "kimi",
minimax: "minimax",
"minimax-cn": "minimax-cn",
hf: "huggingface",
huggingface: "huggingface",
ds: "deepseek",
deepseek: "deepseek",
cmc: "commandcode",
commandcode: "commandcode",
groq: "groq",
xai: "xai",
mistral: "mistral",
pplx: "perplexity",
perplexity: "perplexity",
together: "together",
fireworks: "fireworks",
cerebras: "cerebras",
cohere: "cohere",
nvidia: "nvidia",
nebius: "nebius",
siliconflow: "siliconflow",
hyp: "hyperbolic",
hyperbolic: "hyperbolic",
dg: "deepgram",
deepgram: "deepgram",
aai: "assemblyai",
assemblyai: "assemblyai",
nb: "nanobanana",
nanobanana: "nanobanana",
ch: "chutes",
chutes: "chutes",
ark: "volcengine-ark",
"volcengine-ark": "volcengine-ark",
byteplus: "byteplus",
bpm: "byteplus",
cursor: "cursor",
vx: "vertex",
vertex: "vertex",
vxp: "vertex-partner",
"vertex-partner": "vertex-partner",
// Web cookie providers
gw: "grok-web",
"grok-web": "grok-web",
pw: "perplexity-web",
"perplexity-web": "perplexity-web",
mimo: "xiaomi-mimo",
"xiaomi-mimo": "xiaomi-mimo",
xmtp: "xiaomi-tokenplan",
"xiaomi-tokenplan": "xiaomi-tokenplan",
cf: "cloudflare-ai",
"cloudflare-ai": "cloudflare-ai",
// Image/video providers
fal: "fal-ai",
"fal-ai": "fal-ai",
stability: "stability-ai",
"stability-ai": "stability-ai",
bfl: "black-forest-labs",
"black-forest-labs": "black-forest-labs",
recraft: "recraft",
topaz: "topaz",
runway: "runwayml",
runwayml: "runwayml",
// Embedding/rerank
jina: "jina-ai",
"jina-ai": "jina-ai",
// TTS
polly: "aws-polly",
"aws-polly": "aws-polly",
// Free-tier providers (synced from OmniRoute)
agentrouter: "agentrouter",
aimlapi: "aimlapi",
aiml: "aimlapi",
novita: "novita",
modal: "modal",
mdl: "modal",
reka: "reka",
nlpcloud: "nlpcloud",
nlpc: "nlpcloud",
bazaarlink: "bazaarlink",
bzl: "bazaarlink",
completions: "completions",
cpl: "completions",
enally: "enally",
enly: "enally",
freetheai: "freetheai",
fta: "freetheai",
llm7: "llm7",
lepton: "lepton",
kluster: "kluster",
ai21: "ai21",
"inference-net": "inference-net",
inet: "inference-net",
predibase: "predibase",
bytez: "bytez",
morph: "morph",
longcat: "longcat",
lc: "longcat",
puter: "puter",
pu: "puter",
uncloseai: "uncloseai",
unc: "uncloseai",
scaleway: "scaleway",
scw: "scaleway",
deepinfra: "deepinfra",
sambanova: "sambanova",
samba: "sambanova",
nscale: "nscale",
baseten: "baseten",
publicai: "publicai",
"nous-research": "nous-research",
nous: "nous-research",
glhf: "glhf",
bb: "blackbox",
blackbox: "blackbox",
};
const ALIAS_TO_PROVIDER_ID = { ...MEDIA_ONLY_ALIASES };
for (const entry of REGISTRY) {
ALIAS_TO_PROVIDER_ID[entry.id] = entry.id;
if (entry.alias) ALIAS_TO_PROVIDER_ID[entry.alias] = entry.id;
for (const a of entry.aliases || []) ALIAS_TO_PROVIDER_ID[a] = entry.id;
}
/**
* Resolve provider alias to provider ID
*/
@@ -241,6 +116,15 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
};
}
// Config-driven prefix → provider inference (first match wins, fallback "openai").
const MODEL_PREFIX_PROVIDERS = [
[/^claude-/, "anthropic"],
[/^gemini-/, "gemini"],
[/^gpt-/, "openai"],
[/^o[134]/, "openai"],
[/^deepseek-/, "openrouter"],
];
/**
* Infer provider from model name prefix
* Used as fallback when no provider prefix or alias is given
@@ -248,12 +132,5 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
function inferProviderFromModelName(modelName) {
if (!modelName) return "openai";
const m = modelName.toLowerCase();
if (m.startsWith("claude-")) return "anthropic";
if (m.startsWith("gemini-")) return "gemini";
if (m.startsWith("gpt-")) return "openai";
if (m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))
return "openai";
if (m.startsWith("deepseek-")) return "openrouter";
// Default fallback
return "openai";
return MODEL_PREFIX_PROVIDERS.find(([re]) => re.test(m))?.[1] || "openai";
}

View File

@@ -3,8 +3,10 @@ import {
isUnrecoverableRefreshError,
refreshTokenByProvider,
} from "./tokenRefresh.js";
import { PROVIDER_OAUTH } from "../providers/index.js";
export const CODEX_MAX_REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000;
// Single source: codex.oauth.maxRefreshAgeMs (8 days) — proactive refresh window
export const CODEX_MAX_REFRESH_AGE_MS = PROVIDER_OAUTH["codex"]?.maxRefreshAgeMs;
const refreshLocks = new Map();
@@ -35,9 +37,9 @@ export function getCredentialLastRefreshMs(credentials) {
);
}
export function isCodexRefreshStale(credentials, nowMs = Date.now()) {
export function isCodexRefreshStale(credentials, nowMs = Date.now(), maxAgeMs = CODEX_MAX_REFRESH_AGE_MS) {
const lastRefreshMs = getCredentialLastRefreshMs(credentials);
return !lastRefreshMs || nowMs - lastRefreshMs >= CODEX_MAX_REFRESH_AGE_MS;
return !lastRefreshMs || nowMs - lastRefreshMs >= maxAgeMs;
}
export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now()) {
@@ -48,7 +50,9 @@ export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now
return true;
}
if (provider === "codex" && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs)) {
// Proactive stale refresh for providers declaring oauth.maxRefreshAgeMs (e.g. codex)
const maxAgeMs = PROVIDER_OAUTH[provider]?.maxRefreshAgeMs;
if (maxAgeMs && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs, maxAgeMs)) {
return true;
}
@@ -101,8 +105,9 @@ export function mergeRefreshedCredentials(provider, currentCredentials, refreshe
next.copilotTokenExpiresAt = refreshedCredentials.copilotTokenExpiresAt;
}
// trackRefreshAt providers (e.g. codex) always stamp lastRefreshAt for staleness tracking
if (
provider === "codex" ||
PROVIDER_OAUTH[provider]?.trackRefreshAt ||
next.accessToken ||
next.apiKey ||
next.token ||

View File

@@ -1,14 +1,14 @@
import { PROVIDERS } from "../config/providers.js";
import { buildClineHeaders } from "../../src/shared/utils/clineAuth.js";
import { OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
const OPENAI_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.openai.com/v1",
baseUrl: OPENAI_COMPAT_BASE,
};
const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
const ANTHROPIC_COMPATIBLE_DEFAULTS = {
baseUrl: "https://api.anthropic.com/v1",
baseUrl: ANTHROPIC_COMPAT_BASE,
};
function isOpenAICompatible(provider) {
@@ -24,27 +24,6 @@ function getOpenAICompatibleType(provider) {
return provider.includes("responses") ? "responses" : "chat";
}
function buildOpenAICompatibleUrl(baseUrl, apiType) {
const normalized = baseUrl.replace(/\/$/, "");
const path = apiType === "responses" ? "/responses" : "/chat/completions";
return `${normalized}${path}`;
}
function buildAnthropicCompatibleUrl(baseUrl) {
const normalized = baseUrl.replace(/\/$/, "");
return `${normalized}/messages`;
}
function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) {
const fallback = (fallbackBaseUrl || "").replace(/\/chat\/completions$/, "");
const raw = typeof resourceUrl === "string" ? resourceUrl.trim() : "";
if (!raw) return fallback;
if (raw.startsWith("http://") || raw.startsWith("https://")) {
return raw.replace(/\/$/, "");
}
return `https://${raw.replace(/\/$/, "")}/v1`;
}
// Detect request format from body structure
export function detectFormat(body) {
// OpenAI Responses API: has input (array or string) instead of messages[]
@@ -125,8 +104,8 @@ export function detectFormat(body) {
return "openai";
}
// Get provider config
export function getProviderConfig(provider) {
// Get provider config (internal — no external runtime consumer)
function getProviderConfig(provider) {
if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider);
return {
@@ -145,180 +124,6 @@ export function getProviderConfig(provider) {
return PROVIDERS[provider] || PROVIDERS.openai;
}
// Get number of fallback URLs for provider (for retry logic)
export function getProviderFallbackCount(provider) {
const config = getProviderConfig(provider);
return config.baseUrls?.length || 1;
}
// Build provider URL
export function buildProviderUrl(provider, model, stream = true, options = {}) {
if (isOpenAICompatible(provider)) {
const apiType = getOpenAICompatibleType(provider);
const baseUrl = options?.baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl;
return buildOpenAICompatibleUrl(baseUrl, apiType);
}
if (isAnthropicCompatible(provider)) {
const baseUrl = options?.baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl;
return buildAnthropicCompatibleUrl(baseUrl);
}
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": {
// Use baseUrlIndex from options or default to 0
const urlIndex = options?.baseUrlIndex || 0;
const baseUrl = config.baseUrls[urlIndex] || config.baseUrls[0];
const path = stream ? "/v1internal:streamGenerateContent?alt=sse" : "/v1internal:generateContent";
return `${baseUrl}${path}`;
}
case "codex":
return config.baseUrl;
case "qwen": {
const baseUrl = buildQwenBaseUrl(options?.qwenResourceUrl, config.baseUrl);
return `${baseUrl}/chat/completions`;
}
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
// Specific override for Anthropic Compatible
if (isAnthropicCompatible(provider)) {
if (credentials.apiKey) {
headers["x-api-key"] = credentials.apiKey;
// Do NOT send Authorization header when apiKey is present for Anthropic Compatible
// as it causes issues with some providers (e.g. opencode.ai)
} else if (credentials.accessToken) {
headers["Authorization"] = `Bearer ${credentials.accessToken}`;
}
// Add default Anthropic version if not present (some proxies require it)
if (!headers["anthropic-version"]) {
headers["anthropic-version"] = "2023-06-01";
}
} else {
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 "cline":
Object.assign(headers, buildClineHeaders(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;
case "vertex":
case "vertex-partner":
// Vertex uses async token minting — headers are set by VertexExecutor._buildHeadersAsync()
// Do NOT set Authorization here; it would leak the raw SA JSON as Bearer token
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) {
if (isOpenAICompatible(provider)) {
@@ -331,6 +136,16 @@ export function getTargetFormat(provider) {
return config.format || "openai";
}
// Resolve which transport to use for a provider given the client sourceFormat.
// Multi-endpoint providers (transport.transports[]) pick the entry matching sourceFormat
// to avoid lossy translation; falls back to the default transport when no match.
export function resolveTransport(provider, sourceFormat) {
const config = PROVIDERS[provider];
const transports = config?.transports;
if (!Array.isArray(transports) || !transports.length) return null;
return transports.find(t => t.format === sourceFormat) || null;
}
// Check if last message is from user
export function isLastMessageFromUser(body) {
const messages = body.messages || body.contents;

View File

@@ -15,10 +15,10 @@
import { createHash } from "crypto";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { buildCosyHeaders } from "@/lib/qoder/cosy.js";
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
import {
QODER_MODEL_LIST_URL,
} from "@/lib/qoder/constants.js";
} from "../shared/qoder/constants.js";
const FETCH_TIMEOUT_MS = 15_000;
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog

View File

@@ -1,73 +1,37 @@
import { PROVIDERS } from "../config/providers.js";
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
import {
refreshXaiToken,
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshKiroToken,
refreshIflowToken,
refreshGitHubToken,
refreshCopilotToken,
refreshCodebuddyToken,
classifyOAuthRefreshError,
} from "./tokenRefresh/providers.js";
// xAI refresh — wraps the class method from src/lib/oauth/services/xai.js so
// the token-refresh switches below can stay flat (one function per provider).
let _xaiServiceSingleton = null;
async function refreshXaiToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("xai", refreshToken, async () => {
try {
if (!_xaiServiceSingleton) {
const mod = await import("../../src/lib/oauth/services/xai.js");
_xaiServiceSingleton = new mod.XaiService();
}
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
idToken: tokens.id_token,
};
} catch (e) {
log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
const msg = String(e?.message || "");
if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
return { error: "invalid_grant" };
}
return null;
}
}, log);
}
// Re-export all provider refresh functions (preserves public API for all consumers)
export {
refreshAccessToken,
refreshClaudeOAuthToken,
refreshGoogleToken,
refreshQwenToken,
refreshCodexToken,
refreshKiroToken,
refreshIflowToken,
refreshGitHubToken,
refreshCopilotToken,
refreshCodebuddyToken,
classifyOAuthRefreshError,
};
// Default token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke)
const REFRESH_RESULT_TTL_MS = 10_000;
const refreshDedupCache = new Map();
async function dedupRefresh(provider, oldToken, fn, log) {
if (!oldToken) return fn();
const key = `${provider}:${oldToken}`;
const hit = refreshDedupCache.get(key);
if (hit) {
if (hit.promise) {
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
return hit.promise;
}
if (hit.expiresAt > Date.now()) {
log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
return hit.result;
}
refreshDedupCache.delete(key);
}
const promise = (async () => {
try {
const result = await fn();
refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
return result;
} catch (err) {
refreshDedupCache.delete(key);
throw err;
}
})();
refreshDedupCache.set(key, { promise });
return promise;
}
// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth)
export function isUnrecoverableRefreshError(result) {
return (
result &&
@@ -79,652 +43,123 @@ export function isUnrecoverableRefreshError(result) {
);
}
// Get provider-specific refresh lead time, falls back to default buffer
export function getRefreshLeadMs(provider) {
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
}
/**
* 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;
}
return dedupRefresh(provider, refreshToken, async () => {
export function parseVertexSaJson(apiKey) {
if (typeof apiKey !== "string") 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 parsed = JSON.parse(apiKey);
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
return parsed;
}
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;
}
}, log);
}
/**
* Specialized refresh for Claude OAuth tokens
*/
export async function refreshClaudeOAuthToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("claude", refreshToken, async () => {
try {
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, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}, log);
}
/**
* Specialized refresh for Google providers (Gemini, Antigravity)
*/
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
if (!refreshToken) return null;
return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
try {
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, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
return null;
}
}, log);
}
/**
* Specialized refresh for Qwen OAuth tokens
*/
export async function refreshQwenToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("qwen", refreshToken, async () => {
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,
providerSpecificData: tokens.resource_url
? { resourceUrl: tokens.resource_url }
: undefined,
};
} 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;
}, log);
}
export function classifyOAuthRefreshError(errorText = "", status = 0) {
let parsed = null;
try {
parsed = errorText ? JSON.parse(errorText) : null;
} catch {
parsed = null;
}
const code = parsed?.error?.code || parsed?.error || parsed?.error_code || "";
const description = parsed?.error_description || parsed?.message || errorText || "";
const combined = `${code} ${description}`.toLowerCase();
const permanent = [
"refresh_token_expired",
"refresh_token_reused",
"refresh_token_invalidated",
"invalid_grant",
].some((marker) => combined.includes(marker));
return { status, code, description, permanent };
}
/**
* Specialized refresh for Codex (OpenAI) OAuth tokens.
* OpenAI uses rotating (one-time-use) refresh tokens.
* Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid,
* so callers stop retrying and request re-authentication.
*/
export async function refreshCodexToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("codex", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: PROVIDERS.codex.clientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorText = await response.text();
const failure = classifyOAuthRefreshError(errorText, response.status);
if (failure.permanent) {
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
status: response.status,
code: failure.code,
});
return { error: "unrecoverable_refresh_error", code: failure.code };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
code: failure.code,
permanent: failure.permanent,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
hasIdToken: !!tokens.id_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
return null;
}
}, log);
}
/**
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
*/
// Backfill missing Kiro profileArn on refresh so existing IDC connections self-heal
async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) {
if (providerSpecificData?.profileArn) return {};
let profileArn = refreshedArn?.trim?.() || null;
if (!profileArn) {
const { fetchKiroProfileArn } = await import("../../src/lib/oauth/providers.js");
profileArn = await fetchKiroProfileArn(accessToken);
}
return profileArn ? { providerSpecificData: { profileArn } } : {};
}
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
if (!refreshToken) return null;
return dedupRefresh("kiro", refreshToken, async () => {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint = isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const response = await proxyAwareFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientId,
clientSecret: clientSecret,
refreshToken: refreshToken,
grantType: "refresh_token",
}),
}, proxyOptions);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
};
}
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "kiro-cli/1.0.0",
},
body: JSON.stringify({
refreshToken: refreshToken,
}),
}, proxyOptions);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
};
}, log);
}
/**
* Specialized refresh for iFlow OAuth tokens
*/
export async function refreshIflowToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("iflow", refreshToken, async () => {
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
// Cache Vertex tokens keyed by service account email { token, expiresAt }
const vertexTokenCache = new Map();
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,
}),
});
export async function refreshVertexToken(saJson, log) {
const cacheKey = saJson.client_email;
const cached = vertexTokenCache.get(cacheKey);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
status: response.status,
error: errorText,
});
return null;
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
return { accessToken: cached.token, expiresAt: cached.expiresAt };
}
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,
};
}, log);
}
/**
* Specialized refresh for GitHub Copilot OAuth tokens
*/
export async function refreshGitHubToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("github", refreshToken, async () => {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.github.clientId,
};
if (PROVIDERS.github.clientSecret) {
params.client_secret = PROVIDERS.github.clientSecret;
}
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params),
});
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,
};
}, log);
}
/**
* Refresh GitHub Copilot token using GitHub access token
*/
export async function refreshCopilotToken(githubAccessToken, log) {
if (!githubAccessToken) return null;
return dedupRefresh("copilot", githubAccessToken, async () => {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
"Authorization": `token ${githubAccessToken}`,
"User-Agent": GITHUB_COPILOT.USER_AGENT,
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
"Accept": "application/json",
"x-github-api-version": GITHUB_COPILOT.API_VERSION
}
const { SignJWT, importPKCS8 } = await import("jose");
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
.setProtectedHeader({ alg: "RS256" })
.setIssuer(saJson.client_email)
.setAudience(OAUTH_ENDPOINTS.google.token)
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(privateKey);
const res = await fetch(OAUTH_ENDPOINTS.google.token, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
status: response.status,
error: errorText
});
if (!res.ok) {
const err = await res.text();
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
return null;
}
const data = await response.json();
const { access_token, expires_in } = await res.json();
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
hasToken: !!data.token,
expiresAt: data.expires_at
});
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
return {
token: data.token,
expiresAt: data.expires_at
};
return { accessToken: access_token, expiresAt };
} catch (error) {
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
error: error.message
});
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
return null;
}
}, log);
}
/**
* Get access token for a specific provider (with in-flight dedup).
* If a refresh is already in-flight for same provider+token, share the promise
* to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke.
*/
function vertexRefreshHandler(c, log) {
const saJson = parseVertexSaJson(c.apiKey);
if (!saJson) return null;
return refreshVertexToken(saJson, log);
}
const REFRESH_HANDLERS = {
"gemini-cli": (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS["gemini-cli"].clientId, PROVIDERS["gemini-cli"].clientSecret, log),
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
claude: (c, log) => refreshClaudeOAuthToken(c.refreshToken, log),
codex: (c, log) => refreshCodexToken(c.refreshToken, log),
qwen: (c, log) => refreshQwenToken(c.refreshToken, log),
iflow: (c, log) => refreshIflowToken(c.refreshToken, log),
github: (c, log) => refreshGitHubToken(c.refreshToken, log),
kiro: (c, log) => refreshKiroToken(c.refreshToken, c.providerSpecificData, log),
xai: (c, log) => refreshXaiToken(c.refreshToken, log),
"codebuddy-cn": (c, log) => refreshCodebuddyToken(c.refreshToken, log),
vertex: vertexRefreshHandler,
"vertex-partner": vertexRefreshHandler
};
export async function getAccessToken(provider, credentials, log) {
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
return null;
}
// Dedup is handled inside each refreshXxxToken function
return _getAccessTokenInternal(provider, credentials, log);
}
async function _getAccessTokenInternal(provider, credentials, log) {
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);
case "kiro":
return await refreshKiroToken(
credentials.refreshToken,
credentials.providerSpecificData,
log
);
case "xai":
return await refreshXaiToken(credentials.refreshToken, log);
case "vertex":
case "vertex-partner": {
const saJson = parseVertexSaJson(credentials.apiKey);
if (!saJson) return null;
return await refreshVertexToken(saJson, log);
}
default:
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
return null;
if (provider === "gemini") {
return refreshGoogleToken(credentials.refreshToken, PROVIDERS.gemini.clientId, PROVIDERS.gemini.clientSecret, log);
}
const handler = REFRESH_HANDLERS[provider];
if (!handler) {
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
return null;
}
return handler(credentials, log);
}
/**
* 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);
case "kiro":
return refreshKiroToken(
credentials.refreshToken,
credentials.providerSpecificData,
log
);
case "xai":
return refreshXaiToken(credentials.refreshToken, log);
case "vertex":
case "vertex-partner": {
const saJson = parseVertexSaJson(credentials.apiKey);
if (!saJson) return null;
return refreshVertexToken(saJson, log);
}
default:
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
}
const handler = REFRESH_HANDLERS[provider];
return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log);
}
/**
* Format credentials for provider
*/
export function formatProviderCredentials(provider, credentials, log) {
const config = PROVIDERS[provider];
if (!config) {
@@ -774,9 +209,6 @@ export function formatProviderCredentials(provider, credentials, log) {
}
}
/**
* Get all access tokens for a user
*/
export async function getAllAccessTokens(userInfo, log) {
const results = {};
@@ -797,89 +229,6 @@ export async function getAllAccessTokens(userInfo, log) {
return results;
}
/**
* Parse Vertex AI Service Account JSON from apiKey string
*/
export function parseVertexSaJson(apiKey) {
if (typeof apiKey !== "string") return null;
try {
const parsed = JSON.parse(apiKey);
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
return parsed;
}
return null;
} catch {
return null;
}
}
// Cache Vertex tokens keyed by service account email { token, expiresAt }
const vertexTokenCache = new Map();
/**
* Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI
* using Service Account JSON + jose (RS256 JWT assertion flow).
* Token is cached until 5 minutes before expiry.
*/
export async function refreshVertexToken(saJson, log) {
const cacheKey = saJson.client_email;
const cached = vertexTokenCache.get(cacheKey);
// Return cached token if still valid (5-min buffer)
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
return { accessToken: cached.token, expiresAt: cached.expiresAt };
}
try {
const { SignJWT, importPKCS8 } = await import("jose");
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
.setProtectedHeader({ alg: "RS256" })
.setIssuer(saJson.client_email)
.setAudience("https://oauth2.googleapis.com/token")
.setIssuedAt(now)
.setExpirationTime(now + 3600)
.sign(privateKey);
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
}),
});
if (!res.ok) {
const err = await res.text();
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
return null;
}
const { access_token, expires_in } = await res.json();
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
return { accessToken: access_token, expiresAt };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
return null;
}
}
/**
* 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) {

View File

@@ -0,0 +1,31 @@
const REFRESH_RESULT_TTL_MS = 10_000;
const refreshDedupCache = new Map();
export async function dedupRefresh(provider, oldToken, fn, log) {
if (!oldToken) return fn();
const key = `${provider}:${oldToken}`;
const hit = refreshDedupCache.get(key);
if (hit) {
if (hit.promise) {
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
return hit.promise;
}
if (hit.expiresAt > Date.now()) {
log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
return hit.result;
}
refreshDedupCache.delete(key);
}
const promise = (async () => {
try {
const result = await fn();
refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
return result;
} catch (err) {
refreshDedupCache.delete(key);
throw err;
}
})();
refreshDedupCache.set(key, { promise });
return promise;
}

View File

@@ -0,0 +1,580 @@
import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js";
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js";
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { dedupRefresh } from "./dedup.js";
let _xaiServiceSingleton = null;
export async function refreshXaiToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("xai", refreshToken, async () => {
try {
if (!_xaiServiceSingleton) {
const mod = await import("../../../src/lib/oauth/services/xai.js");
_xaiServiceSingleton = new mod.XaiService();
}
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
idToken: tokens.id_token,
};
} catch (e) {
log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
const msg = String(e?.message || "");
if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
return { error: "invalid_grant" };
}
return null;
}
}, log);
}
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;
}
return dedupRefresh(provider, refreshToken, async () => {
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;
}
}, log);
}
export async function refreshClaudeOAuthToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("claude", refreshToken, async () => {
try {
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, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
return null;
}
}, log);
}
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
if (!refreshToken) return null;
return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
try {
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, expiresIn: tokens.expires_in });
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
return null;
}
}, log);
}
export async function refreshQwenToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("qwen", refreshToken, async () => {
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,
providerSpecificData: tokens.resource_url
? { resourceUrl: tokens.resource_url }
: undefined,
};
} 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;
}, log);
}
export function classifyOAuthRefreshError(errorText = "", status = 0) {
let parsed = null;
try {
parsed = errorText ? JSON.parse(errorText) : null;
} catch {
parsed = null;
}
const code = parsed?.error?.code || parsed?.error || parsed?.error_code || "";
const description = parsed?.error_description || parsed?.message || errorText || "";
const combined = `${code} ${description}`.toLowerCase();
const permanent = [
"refresh_token_expired",
"refresh_token_reused",
"refresh_token_invalidated",
"invalid_grant",
].some((marker) => combined.includes(marker));
return { status, code, description, permanent };
}
export async function refreshCodexToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("codex", refreshToken, async () => {
try {
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: PROVIDERS.codex.clientId,
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const errorText = await response.text();
const failure = classifyOAuthRefreshError(errorText, response.status);
if (failure.permanent) {
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
status: response.status,
code: failure.code,
});
return { error: "unrecoverable_refresh_error", code: failure.code };
}
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
status: response.status,
error: errorText,
code: failure.code,
permanent: failure.permanent,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
hasNewAccessToken: !!tokens.access_token,
hasNewRefreshToken: !!tokens.refresh_token,
hasIdToken: !!tokens.id_token,
expiresIn: tokens.expires_in,
});
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
return null;
}
}, log);
}
async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) {
if (providerSpecificData?.profileArn) return {};
let profileArn = refreshedArn?.trim?.() || null;
if (!profileArn) {
const { fetchKiroProfileArn } = await import("../../../src/lib/oauth/providers.js");
profileArn = await fetchKiroProfileArn(accessToken);
}
return profileArn ? { providerSpecificData: { profileArn } } : {};
}
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
if (!refreshToken) return null;
return dedupRefresh("kiro", refreshToken, async () => {
const authMethod = providerSpecificData?.authMethod;
const clientId = providerSpecificData?.clientId;
const clientSecret = providerSpecificData?.clientSecret;
const region = providerSpecificData?.region;
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint = isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const response = await proxyAwareFetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientId,
clientSecret: clientSecret,
refreshToken: refreshToken,
grantType: "refresh_token",
}),
}, proxyOptions);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
};
}
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "kiro-cli/1.0.0",
},
body: JSON.stringify({
refreshToken: refreshToken,
}),
}, proxyOptions);
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
status: response.status,
error: errorText,
});
return null;
}
const tokens = await response.json();
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
hasNewAccessToken: !!tokens.accessToken,
expiresIn: tokens.expiresIn,
});
return {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || refreshToken,
expiresIn: tokens.expiresIn,
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
};
}, log);
}
export async function refreshIflowToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("iflow", refreshToken, async () => {
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,
};
}, log);
}
export async function refreshGitHubToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("github", refreshToken, async () => {
const params = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: PROVIDERS.github.clientId,
};
if (PROVIDERS.github.clientSecret) {
params.client_secret = PROVIDERS.github.clientSecret;
}
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams(params),
});
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,
};
}, log);
}
export async function refreshCopilotToken(githubAccessToken, log) {
if (!githubAccessToken) return null;
return dedupRefresh("copilot", githubAccessToken, async () => {
try {
const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, {
headers: {
"Authorization": `token ${githubAccessToken}`,
"User-Agent": GITHUB_COPILOT.USER_AGENT,
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
"Accept": "application/json",
"x-github-api-version": GITHUB_COPILOT.API_VERSION
}
});
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;
}
}, log);
}
// CodeBuddy (Tencent) refresh — POST /v2/plugin/auth/token/refresh with the
// refresh token carried in the X-Refresh-Token header (not a form body),
// matching the official CodeBuddy CLI. Response: { code: 0, data: <token> }.
export async function refreshCodebuddyToken(refreshToken, log) {
if (!refreshToken) return null;
return dedupRefresh("codebuddy-cn", refreshToken, async () => {
const oauth = PROVIDER_OAUTH["codebuddy-cn"] || {};
const response = await fetch(oauth.refreshUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": oauth.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-Refresh-Token": refreshToken,
"X-Auth-Refresh-Source": "plugin",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) {
const errorText = await response.text();
log?.error?.("TOKEN_REFRESH", "Failed to refresh CodeBuddy token", {
status: response.status,
error: errorText,
});
return null;
}
const data = await response.json();
if (data.code !== 0 || !data.data?.accessToken) {
log?.error?.("TOKEN_REFRESH", "CodeBuddy token refresh returned no token", {
code: data.code,
msg: data.msg,
});
return null;
}
log?.info?.("TOKEN_REFRESH", "Successfully refreshed CodeBuddy token", {
hasNewAccessToken: !!data.data.accessToken,
hasNewRefreshToken: !!data.data.refreshToken,
expiresIn: data.data.expiresIn,
});
return {
accessToken: data.data.accessToken,
refreshToken: data.data.refreshToken || refreshToken,
expiresIn: data.data.expiresIn,
};
}, log);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
/**
* Claude usage handler
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { ANTHROPIC_API_VERSION } from "../../providers/shared.js";
import { U, parseResetTime } from "./shared.js";
// Claude API config (urls from registry, apiVersion is header logic kept here)
const CLAUDE_CONFIG = {
oauthUsageUrl: U("claude").oauthUrl,
usageUrl: U("claude").orgUrl,
settingsUrl: U("claude").settingsUrl,
apiVersion: ANTHROPIC_API_VERSION,
};
// OAuth usage endpoint rate-limits (429); cool down per-token to stop hammering it.
// Only the quota endpoint is affected — chat with the same token still works.
const OAUTH_429_COOLDOWN_MS = 180000;
const oauthCooldown = new Map();
export async function getClaudeUsage(accessToken, proxyOptions = null) {
try {
// Skip OAuth usage call while this token is cooling down from a recent 429
const cooldownUntil = oauthCooldown.get(accessToken);
if (cooldownUntil && Date.now() < cooldownUntil) {
return await getClaudeUsageLegacy(accessToken, proxyOptions);
}
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"anthropic-beta": "oauth-2025-04-20",
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
}, proxyOptions);
if (oauthResponse.ok) {
const data = await oauthResponse.json();
const quotas = {};
// utilization = % USED (e.g. 87 means 87% used, 13% remaining)
const hasUtilization = (window) =>
window && typeof window === "object" && typeof window.utilization === "number";
const createQuotaObject = (window) => {
const used = window.utilization;
const remaining = Math.max(0, 100 - used);
return {
used,
total: 100,
remaining,
remainingPercentage: remaining,
resetAt: parseResetTime(window.resets_at),
unlimited: false,
};
};
if (hasUtilization(data.five_hour)) {
quotas["session (5h)"] = createQuotaObject(data.five_hour);
}
if (hasUtilization(data.seven_day)) {
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
}
// Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus)
for (const [key, value] of Object.entries(data)) {
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
const modelName = key.replace("seven_day_", "");
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
}
}
return {
plan: "Claude Code",
extraUsage: data.extra_usage ?? null,
quotas,
};
}
// Cool down OAuth usage polling after a 429 (quota endpoint only)
if (oauthResponse.status === 429) {
oauthCooldown.set(accessToken, Date.now() + OAUTH_429_COOLDOWN_MS);
}
// Fallback: legacy settings + org usage endpoint
console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`);
return await getClaudeUsageLegacy(accessToken, proxyOptions);
} catch (error) {
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
}
}
/**
* Legacy Claude usage for API key / org admin users
*/
async function getClaudeUsageLegacy(accessToken, proxyOptions = null) {
try {
const settingsResponse = await proxyAwareFetch(CLAUDE_CONFIG.settingsUrl, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
}, proxyOptions);
if (settingsResponse.ok) {
const settings = await settingsResponse.json();
if (settings.organization_id) {
const usageResponse = await proxyAwareFetch(
CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id),
{
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"anthropic-version": CLAUDE_CONFIG.apiVersion,
},
},
proxyOptions
);
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.",
};
}
return { message: "Claude connected. Usage API requires admin permissions." };
} catch (error) {
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
}
}

View File

@@ -0,0 +1,131 @@
/**
* CodeBuddy CN usage handler
*
* Scoped to the "codebuddy-cn" provider specifically — a future "codebuddy-intl"
* variant would get its own handler/endpoint, so keep this CN-only.
*
* Quota lives behind a Tencent billing endpoint (POST, payload wrapped twice
* under data.Response.Data). It mixes two credit types that must NOT be merged:
*
* - Refill / base ("基础体验包"): a recurring allowance whose cycle resets long
* before the resource itself expires (CycleEndTime << DeductionEndTime). The
* live numbers live in the *Cycle* fields (e.g. CycleCapacityUsed 6.54 / 500)
* and resetAt is the next monthly refresh.
* - Bonus ("活动赠送包"): one-shot credits that run a single cycle and then
* expire for good (CycleEndTime == DeductionEndTime). Numbers live in the
* plain Capacity fields.
*
* We surface one quota row per package — a cadence label (Monthly/Weekly/Daily)
* for refill packs, "Bonus Pack N" for bonus packs (soonest-expiring first).
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { PROVIDERS } from "../../providers/index.js";
import { U, parseResetTime } from "./shared.js";
const PROVIDER_ID = "codebuddy-cn";
// Prefer the *Precise string fields (exact), fall back to the numeric ones.
function num(precise, plain) {
const n = Number(precise ?? plain);
return Number.isFinite(n) ? n : 0;
}
// Label a refill pack by its cycle length (Monthly is the common CodeBuddy case).
function refillCadence(acc) {
const start = parseResetTime(acc.CycleStartTime);
const end = parseResetTime(acc.CycleEndTime);
if (start && end) {
const days = (new Date(end).getTime() - new Date(start).getTime()) / 86400000;
if (days <= 1.5) return "Daily";
if (days <= 10) return "Weekly";
}
return "Monthly";
}
export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
const token = accessToken || apiKey;
if (!token) {
return { message: "CodeBuddy CN credential not available." };
}
try {
const response = await proxyAwareFetch(U(PROVIDER_ID).url, {
method: "POST",
headers: {
...(PROVIDERS[PROVIDER_ID]?.headers || {}),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: "{}",
}, proxyOptions);
if (response.status === 401 || response.status === 403) {
return { message: "CodeBuddy CN credential invalid or expired." };
}
if (!response.ok) {
return { message: `CodeBuddy CN quota API error (${response.status}).` };
}
const json = await response.json();
if (json?.code !== 0) {
return { message: `CodeBuddy CN quota error: ${json?.msg || "unknown"}` };
}
const data = json?.data?.Response?.Data || {};
const accounts = Array.isArray(data.Accounts) ? data.Accounts : [];
if (accounts.length === 0) {
return { message: "CodeBuddy CN connected. No credit package found." };
}
const cycleEndMs = (acc) => {
const r = parseResetTime(acc.CycleEndTime);
return r ? new Date(r).getTime() : Number.POSITIVE_INFINITY;
};
// Refill packs roll into a new cycle before the resource expires; bonus packs
// end exactly at expiry. >2d gap between cycle end and validity end = refill.
const REFILL_GAP_MS = 2 * 24 * 60 * 60 * 1000;
const isRefill = (acc) => {
const ce = cycleEndMs(acc);
const de = Number(acc.DeductionEndTime);
return Number.isFinite(ce) && Number.isFinite(de) && de - ce > REFILL_GAP_MS;
};
const byExpiry = (a, b) => cycleEndMs(a) - cycleEndMs(b);
const refills = accounts.filter(isRefill).sort(byExpiry);
const bonuses = accounts.filter((a) => !isRefill(a)).sort(byExpiry);
const quotas = {};
// Refill packs first: cadence-labelled, using the *Cycle* balance and
// resetting at the next refresh.
const seenRefill = {};
refills.forEach((acc) => {
const base = refillCadence(acc);
seenRefill[base] = (seenRefill[base] || 0) + 1;
const name = seenRefill[base] > 1 ? `${base} ${seenRefill[base]}` : base;
quotas[name] = {
used: num(acc.CycleCapacityUsedPrecise, acc.CycleCapacityUsed),
total: num(acc.CycleCapacitySizePrecise, acc.CycleCapacitySize),
resetAt: parseResetTime(acc.CycleEndTime),
unlimited: false,
};
});
// Bonus packs: use the lifetime Capacity balance; resetAt is the expiry.
bonuses.forEach((acc, i) => {
quotas[`Bonus Pack ${i + 1}`] = {
used: num(acc.CapacityUsedPrecise, acc.CapacityUsed),
total: num(acc.CapacitySizePrecise, acc.CapacitySize),
resetAt: parseResetTime(acc.CycleEndTime),
unlimited: false,
};
});
const basePkg = refills[0] || accounts[0] || {};
const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN";
return { plan, quotas };
} catch (error) {
return { message: `CodeBuddy CN error: ${error.message}` };
}
}

View File

@@ -0,0 +1,145 @@
/**
* Codex (OpenAI) usage handler
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
// Codex (OpenAI) API config
const CODEX_CONFIG = {
usageUrl: U("codex").url,
resetCreditsConsumeUrl: U("codex").resetCreditsConsumeUrl,
};
function getCodexRateLimitBody(snapshot) {
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
return snapshot.rate_limit && typeof snapshot.rate_limit === "object"
? snapshot.rate_limit
: snapshot;
}
function formatCodexWindow(window) {
const used = Math.max(0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0)));
return {
used,
total: 100,
remaining: Math.max(0, 100 - used),
resetAt: parseResetTime(window?.reset_at ?? window?.resets_at ?? window?.resetAt ?? null),
unlimited: false,
};
}
function appendCodexQuotaWindows(quotas, prefix, snapshot) {
const rateLimit = getCodexRateLimitBody(snapshot);
if (!rateLimit) return false;
const primary = rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary;
const secondary = rateLimit.secondary_window || rateLimit.secondary || snapshot.secondary_window || snapshot.secondary;
let added = false;
if (primary) {
quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary);
added = true;
}
if (secondary) {
quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary);
added = true;
}
return added;
}
function getCodexReviewRateLimit(data) {
if (data.code_review_rate_limit || data.review_rate_limit) {
return data.code_review_rate_limit || data.review_rate_limit;
}
const byLimitId = data.rate_limits_by_limit_id;
if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null;
}
const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : [];
return additional.find((entry) => {
const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase();
return id === "code_review" || id === "codex_review" || id === "review" || id.includes("review");
}) || null;
}
export async function getCodexUsage(accessToken, proxyOptions = null) {
try {
const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
},
}, proxyOptions);
if (!response.ok) {
return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` };
}
const data = await response.json();
const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {};
const reviewRateLimit = getCodexReviewRateLimit(data);
const availableResetCredits = Math.max(0, toFiniteNumber(data.rate_limit_reset_credits?.available_count, 0));
const quotas = {};
appendCodexQuotaWindows(quotas, "", normalRateLimit);
appendCodexQuotaWindows(quotas, "review", reviewRateLimit);
return {
plan: data.plan_type || data.summary?.plan || "unknown",
limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false,
reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false,
resetCredits: { availableCount: availableResetCredits },
quotas,
};
} catch (error) {
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
}
}
// Consume one Codex rate-limit reset credit (irreversible, spends 1 credit)
export async function consumeCodexRateLimitResetCredit(accessToken, redeemRequestId, proxyOptions = null) {
if (!accessToken) {
throw new Error("No Codex access token available. Please re-authorize the connection.");
}
if (!redeemRequestId || typeof redeemRequestId !== "string") {
throw new Error("A redeem request id is required to consume a Codex reset credit.");
}
let response;
let data = null;
try {
response = await proxyAwareFetch(CODEX_CONFIG.resetCreditsConsumeUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
}, proxyOptions);
const text = await response.text();
data = text ? JSON.parse(text) : null;
} catch (error) {
throw new Error(`Failed to consume Codex reset credit: ${error.message}`);
}
const code = data?.code || null;
const windowsReset = toFiniteNumber(data?.windows_reset, 0);
const success = response.ok && (code === "reset" || windowsReset > 0);
return {
ok: success,
noCredit: response.ok && code === "no_credit",
status: response.status,
code,
windowsReset,
message: data?.message || null,
raw: data,
};
}

View File

@@ -0,0 +1,100 @@
/**
* GitHub Copilot usage handler
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { PROVIDER_OAUTH } from "../../providers/index.js";
import { U, parseResetTime } from "./shared.js";
// GitHub API config — single source from registry oauth block
const GITHUB_CONFIG = {
apiVersion: PROVIDER_OAUTH.github?.apiVersion,
userAgent: PROVIDER_OAUTH.github?.userAgent,
};
/**
* GitHub Copilot Usage
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
*/
export async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) {
try {
if (!accessToken) {
throw new Error("No GitHub access token available. Please re-authorize the connection.");
}
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
const response = await proxyAwareFetch(U("github").url, {
headers: {
"Authorization": `token ${accessToken}`,
"Accept": "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot-chat/0.26.7",
},
}, proxyOptions);
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;
const resetAt = parseResetTime(data.quota_reset_date);
return {
plan: data.copilot_plan,
resetDate: data.quota_reset_date,
quotas: {
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
premium_interactions: { ...formatGitHubQuotaSnapshot(snapshots.premium_interactions), resetAt },
},
};
} else if (data.monthly_quotas || data.limited_user_quotas) {
// Free/limited plan format
const monthlyQuotas = data.monthly_quotas || {};
const usedQuotas = data.limited_user_quotas || {};
const resetAt = parseResetTime(data.limited_user_reset_date);
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,
resetAt,
},
completions: {
used: usedQuotas.completions || 0,
total: monthlyQuotas.completions || 0,
unlimited: false,
resetAt,
},
},
};
}
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,
};
}

View File

@@ -0,0 +1,243 @@
/**
* Google usage handlers (Gemini CLI + Antigravity)
*/
import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js";
import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js";
// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here
const ANTIGRAVITY_CONFIG = {
...U("antigravity"),
...ANTIGRAVITY_OAUTH_CLIENT,
userAgent: getPlatformUserAgent(),
};
/**
* Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API.
* Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning
* per-model buckets with remainingFraction + resetTime.
*/
export async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) {
if (!accessToken) {
return { plan: "Free", message: "Gemini CLI access token not available." };
}
try {
// Resolve project id: prefer connection-stored id, else loadCodeAssist lookup.
// #1271: OAuth save stores projectId on the connection, not providerSpecificData.
let projectId = normalizeCloudCodeProjectId(providerSpecificData?.projectId);
let plan = "Free";
if (!projectId) {
const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions);
projectId = normalizeCloudCodeProjectId(subInfo?.cloudaicompanionProject);
plan = subInfo?.currentTier?.name || plan;
}
if (!projectId) {
return {
plan,
message: "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.",
};
}
const response = await fetchWithTimeout(
U("gemini-cli").quotaUrl,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ project: projectId }),
},
10000,
proxyOptions
);
if (!response.ok) {
return { plan, message: `Gemini CLI quota error (${response.status}).` };
}
const data = await response.json();
const quotas = {};
if (Array.isArray(data.buckets)) {
for (const bucket of data.buckets) {
if (!bucket.modelId || bucket.remainingFraction == null) continue;
const remainingFraction = Number(bucket.remainingFraction) || 0;
const total = 1000; // Normalized base, matches antigravity convention
const remaining = Math.round(total * remainingFraction);
const used = Math.max(0, total - remaining);
quotas[bucket.modelId] = {
used,
total,
resetAt: parseResetTime(bucket.resetTime),
remainingPercentage: remainingFraction * 100,
unlimited: false,
};
}
}
return { plan, quotas };
} catch (error) {
return { message: `Gemini CLI error: ${error.message}` };
}
}
/**
* Get Gemini CLI subscription info via loadCodeAssist
*/
async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) {
try {
const response = await fetchWithTimeout(
U("gemini-cli").loadCodeAssistUrl,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ metadata: CLIENT_METADATA }),
},
10000,
proxyOptions
);
if (!response.ok) return null;
return await response.json();
} catch {
return null;
}
}
/**
* Antigravity Usage - Fetch quota from Google Cloud Code API
*/
export async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) {
try {
// Fetch subscription info once — reuse for both projectId and plan
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions);
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.quotaApiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
"X-Client-Name": "antigravity",
"X-Client-Version": "1.107.0",
"x-request-source": "local", // MITM bypass
},
body: JSON.stringify({
...(projectId ? { project: projectId } : {})
}),
}, 10000, proxyOptions);
if (response.status === 403) {
return {
message: "Antigravity quota API access forbidden. Chat may still work.",
quotas: {}
};
}
if (response.status === 401) {
return {
message: "Antigravity quota API authentication expired. Chat may still work.",
quotas: {}
};
}
if (!response.ok) {
throw new Error(`Antigravity API error: ${response.status}`);
}
const data = await response.json();
const quotas = {};
// Parse model quotas (inspired by vscode-antigravity-cockpit)
if (data.models) {
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
const importantModels = [
'gemini-3-flash-agent',
'gemini-3.5-flash-low',
'gemini-3.5-flash-extra-low',
'gemini-pro-agent',
'gemini-3.1-pro-low',
'claude-sonnet-4-6',
'claude-opus-4-6-thinking',
'gpt-oss-120b-medium',
'gemini-3-flash',
// Image generation models
'gemini-3.1-flash-image',
'gemini-3-pro-image',
];
for (const [modelKey, info] of Object.entries(data.models)) {
// Skip models without quota info
if (!info.quotaInfo) {
continue;
}
// Skip internal models and non-important models
if (info.isInternal || !importantModels.includes(modelKey)) {
continue;
}
const remainingFraction = info.quotaInfo.remainingFraction || 0;
const remainingPercentage = remainingFraction * 100;
// Convert percentage to used/total for UI compatibility
const total = 1000; // Normalized base
const remaining = Math.round(total * remainingFraction);
const used = total - remaining;
// Use modelKey as key (matches PROVIDER_MODELS id)
quotas[modelKey] = {
used,
total,
resetAt: parseResetTime(info.quotaInfo.resetTime),
remainingPercentage,
unlimited: false,
displayName: info.displayName || modelKey,
};
}
}
return {
plan: subscriptionInfo?.currentTier?.name || "Unknown",
quotas,
subscriptionInfo,
};
} catch (error) {
console.error("[Antigravity Usage] Error:", error.message, error.cause);
return { message: `Antigravity error: ${error.message}` };
}
}
/**
* Get Antigravity subscription info
*/
async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) {
try {
const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
"x-request-source": "local", // MITM bypass
},
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
}, 10000, proxyOptions);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error("[Antigravity Subscription] Error:", error.message);
return null;
}
}

View File

@@ -0,0 +1,183 @@
/**
* Kiro (AWS CodeWhisperer) usage handler
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { resolveDefaultProfileArn } from "../../config/kiroConstants.js";
import { U, parseResetTime } from "./shared.js";
/**
* Kiro (AWS CodeWhisperer) Usage
*/
function parseKiroQuotaData(data) {
const usageList = data.usageBreakdownList || [];
const quotaInfo = {};
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
usageList.forEach((breakdown) => {
const resourceType = breakdown.resourceType?.toLowerCase() || "unknown";
const used = breakdown.currentUsageWithPrecision || 0;
const total = breakdown.usageLimitWithPrecision || 0;
quotaInfo[resourceType] = {
used,
total,
remaining: total - used,
resetAt,
unlimited: false,
};
// Add free trial if available
if (breakdown.freeTrialInfo) {
const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0;
const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0;
quotaInfo[`${resourceType}_freetrial`] = {
used: freeUsed,
total: freeTotal,
remaining: freeTotal - freeUsed,
resetAt: parseResetTime(breakdown.freeTrialInfo.freeTrialExpiry || resetAt),
unlimited: false,
};
}
});
return {
plan: data.subscriptionInfo?.subscriptionTitle || "Kiro",
quotas: quotaInfo,
};
}
export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) {
const authMethod = providerSpecificData?.authMethod || "builder-id";
// API-key Kiro connections authenticate the quota API the same way the chat
// executor does: a bearer token plus a `tokentype: API_KEY` header so
// CodeWhisperer treats it as a long-lived API key rather than an OIDC token.
// Without this header the GetUsageLimits call is rejected (401/403).
const isApiKey = authMethod === "api_key";
const apiKeyHeaders = isApiKey ? { tokentype: "API_KEY" } : {};
// For api-key auth, never inject the shared default placeholder profileArn —
// CodeWhisperer 403s a request whose profileArn isn't owned by the key's
// account. Only send a profileArn actually resolved for this connection.
const profileArn = isApiKey
? (providerSpecificData?.profileArn || "")
: (providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
const getUsageParams = new URLSearchParams({
isEmailRequired: "true",
origin: "AI_EDITOR",
resourceType: "AGENTIC_REQUEST",
});
// For compatibility, try multiple known Kiro usage endpoints
const attempts = [
{
name: "codewhisperer-get",
run: async () => proxyAwareFetch(
`${U("kiro").cwHost}${U("kiro").limitsPath}?${getUsageParams.toString()}`,
{
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
"x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE",
"user-agent": "aws-sdk-js/1.0.0 KiroIDE",
...apiKeyHeaders,
},
},
proxyOptions
),
},
{
name: "codewhisperer-post",
run: async () => proxyAwareFetch(U("kiro").cwHost, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Content-Type": "application/x-amz-json-1.0",
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
"Accept": "application/json",
...apiKeyHeaders,
},
body: JSON.stringify({
origin: "AI_EDITOR",
...(profileArn ? { profileArn } : {}),
resourceType: "AGENTIC_REQUEST",
}),
}, proxyOptions),
},
{
name: "q-get",
run: async () => {
const params = new URLSearchParams({
origin: "AI_EDITOR",
...(profileArn ? { profileArn } : {}),
resourceType: "AGENTIC_REQUEST",
});
return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
...apiKeyHeaders,
},
}, proxyOptions);
},
},
];
let sawAuthError = false;
const errors = [];
for (const attempt of attempts) {
try {
const response = await attempt.run();
if (!response.ok) {
const errorText = await response.text().catch(() => "");
if (response.status === 401 || response.status === 403) {
sawAuthError = true;
}
errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`);
continue;
}
const data = await response.json();
return parseKiroQuotaData(data);
} catch (error) {
errors.push(`${attempt.name}:${error.message}`);
}
}
if (sawAuthError && authMethod === "idc") {
return {
message: "Kiro quota API is unavailable for the current AWS IAM Identity Center session. Chat may still work. If this persists after renewing your session, reconnect Kiro.",
quotas: {},
};
}
// Social auth (Google/GitHub) - these use a different token format that may not work with AWS CodeWhisperer quota APIs
if (sawAuthError && (authMethod === "google" || authMethod === "github")) {
return {
message: "Kiro quota API authentication expired. Chat may still work.",
quotas: {},
};
}
if (sawAuthError) {
return {
message: "Kiro quota API rejected the current token. Chat may still work.",
quotas: {},
};
}
const fallbackMessage =
errors.length > 0
? `Unable to fetch Kiro usage right now. (${errors[errors.length - 1]})`
: "Unable to fetch Kiro usage right now.";
return {
message: fallbackMessage,
quotas: {},
};
}

View File

@@ -0,0 +1,234 @@
/**
* MiniMax usage handler
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime } from "./shared.js";
// MiniMax usage endpoints (try in order, fallback on transient errors)
const MINIMAX_USAGE_URLS = {
minimax: U("minimax").urls,
"minimax-cn": U("minimax-cn").urls,
};
// ── MiniMax helpers ──────────────────────────────────────────────────────
function getMiniMaxField(model, snakeKey, camelKey) {
if (!model || typeof model !== "object") return null;
return model[snakeKey] ?? model[camelKey] ?? null;
}
function getMiniMaxModelName(model) {
return String(getMiniMaxField(model, "model_name", "modelName") || "").trim();
}
function formatMiniMaxQuotaName(model) {
const rawName = getMiniMaxModelName(model);
if (!rawName) return "MiniMax";
// M3+ shared quota pool: MiniMax reports M-series as a single wildcard
// bucket ("MiniMax-M*"). Newer responses rename it to plain "general".
// Render both as a friendly series label rather than leaking the
// asterisk or the vague "general" word to the UI.
if (rawName === "MiniMax-M*" || rawName === "general") return "M-series";
return rawName
.replace(/[_-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (ch) => ch.toUpperCase())
.replace(/\bTo\b/g, "to")
.replace(/\bTts\b/g, "TTS")
.replace(/\bHd\b/g, "HD");
}
function getMiniMaxProvidedPercent(model, snakeKey, camelKey) {
if (!model || typeof model !== "object") return null;
const raw = model[snakeKey] ?? model[camelKey];
if (raw === null || raw === undefined) return null;
const num = Number(raw);
if (!Number.isFinite(num)) return null;
return Math.max(0, Math.min(100, num));
}
function getMiniMaxSessionTotal(model) {
return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0);
}
function getMiniMaxWeeklyTotal(model) {
return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0);
}
function hasMiniMaxQuota(model) {
// Old format has real count totals; M3-era M-series buckets ship percent-only
// (count fields are 0) so accept those too.
if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true;
if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true;
if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true;
return false;
}
function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) {
const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0;
if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString();
return parseResetTime(getMiniMaxField(model, endSnake, endCamel));
}
function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) {
const safeTotal = Math.max(0, total);
const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal);
const remaining = Math.max(safeTotal - used, 0);
// M-series buckets ship percent-only (count = 0). Prefer the upstream value
// when present, otherwise fall back to the computed percentage. When the
// quota is unbounded (no count) and no upstream percent is available, surface
// the percent anyway as long as it is defined.
const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal);
return {
used,
total: safeTotal,
remaining,
remainingPercentage,
resetAt,
unlimited: false,
};
}
function providedPercentage(provided, remaining, total) {
if (provided !== null && provided !== undefined && Number.isFinite(provided)) {
return Math.max(0, Math.min(100, provided));
}
return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
}
function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) {
const total = getTotal(model);
const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel);
if (total <= 0 && providedPercent === null) return;
const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0);
let effectiveTotal = total;
let effectiveCount = count;
if (total <= 0) {
// M-series bucket: API only ships *_remaining_percent (count = 0). Normalize
// to total=100. The downstream buildMiniMaxQuota treats the count as
// "used" or "remaining" depending on countMeansRemaining, so the synthetic
// count has to match that semantic — otherwise the UI flips the percentage.
effectiveTotal = 100;
const pct = providedPercent;
effectiveCount = countMeansRemaining
? Math.round(effectiveTotal * (pct / 100))
: Math.round(effectiveTotal * (1 - pct / 100));
}
quotas[key] = buildMiniMaxQuota(
effectiveTotal,
effectiveCount,
getMiniMaxResetAt(model, ...resetArgs),
countMeansRemaining,
providedPercent
);
}
/**
* MiniMax Token Plan / Coding Plan usage
*/
export async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) {
if (!apiKey) {
return { message: "MiniMax API key not available." };
}
const usageUrls = MINIMAX_USAGE_URLS[provider] || [];
let lastErrorMessage = "";
for (let index = 0; index < usageUrls.length; index += 1) {
const usageUrl = usageUrls[index];
const canFallback = index < usageUrls.length - 1;
try {
const response = await proxyAwareFetch(usageUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
"Content-Type": "application/json",
},
}, proxyOptions);
const rawText = await response.text();
let payload = {};
if (rawText) {
try { payload = JSON.parse(rawText); } catch { payload = {}; }
}
const baseResp = (payload?.base_resp ?? payload?.baseResp) || {};
const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0;
const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim();
const combined = `${apiStatusMessage} ${rawText}`.trim();
const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i;
if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) {
return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." };
}
if (!response.ok) {
lastErrorMessage = `MiniMax usage endpoint error (${response.status})`;
if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue;
return { message: `MiniMax connected. ${lastErrorMessage}` };
}
if (apiStatusCode !== 0) {
return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` };
}
const modelRemains = payload?.model_remains ?? payload?.modelRemains;
const allModels = Array.isArray(modelRemains) ? modelRemains : [];
const quotaModels = allModels.filter(hasMiniMaxQuota);
if (quotaModels.length === 0) {
return { message: "MiniMax connected. No quota data was returned." };
}
const capturedAtMs = Date.now();
const countMeansRemaining = usageUrl.includes("/coding_plan/remains");
const quotas = {};
for (const model of quotaModels) {
const displayName = formatMiniMaxQuotaName(model);
addMiniMaxQuota(
quotas,
`${displayName} (5h)`,
model,
getMiniMaxSessionTotal,
"current_interval_usage_count",
"currentIntervalUsageCount",
"current_interval_remaining_percent",
"currentIntervalRemainingPercent",
[capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"],
countMeansRemaining
);
addMiniMaxQuota(
quotas,
`${displayName} (7d)`,
model,
getMiniMaxWeeklyTotal,
"current_weekly_usage_count",
"currentWeeklyUsageCount",
"current_weekly_remaining_percent",
"currentWeeklyRemainingPercent",
[capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"],
countMeansRemaining
);
}
if (Object.keys(quotas).length === 0) {
return { message: "MiniMax connected. Unable to extract quota usage." };
}
return { quotas };
} catch (error) {
lastErrorMessage = error.message;
if (!canFallback) break;
}
}
return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." };
}

View File

@@ -0,0 +1,269 @@
/**
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U } from "./shared.js";
// GLM quota endpoints (region-aware) — url from registry transport.usage
const GLM_QUOTA_URLS = {
international: U("glm").url,
china: U("glm-cn").url,
};
// Vercel AI Gateway credits endpoint
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
/**
* Qwen Usage
*/
export 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
*/
export 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." };
}
}
/**
* Ollama Cloud Usage
* Ollama Cloud uses an API key from ollama.com/settings/keys
* and has no public usage API — free tier has light usage limits (resets every 5h & 7d).
* This returns an informational message with the plan details.
*/
export async function getOllamaUsage(accessToken, providerSpecificData) {
try {
// Ollama Cloud does not expose a public quota/usage API.
// The provider is configured as noAuth with a notice explaining limits.
// We return a graceful message so the UI shows a friendly state instead of an error.
const plan = providerSpecificData?.plan || "Free";
return {
plan,
message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.",
quotas: [],
};
} catch (error) {
return { message: "Unable to fetch Ollama Cloud usage." };
}
}
/**
* GLM Coding Plan usage (international + China regions)
*/
export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
if (!apiKey) {
return { message: "GLM API key not available." };
}
const region = provider === "glm-cn" ? "china" : "international";
const quotaUrl = GLM_QUOTA_URLS[region];
try {
const response = await proxyAwareFetch(quotaUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}, proxyOptions);
if (!response.ok) {
if (response.status === 401) {
return { message: "GLM API key invalid or expired." };
}
return { message: `GLM quota API error (${response.status}).` };
}
const json = await response.json();
const data = json?.data && typeof json.data === "object" ? json.data : {};
const limits = Array.isArray(data.limits) ? data.limits : [];
const quotas = {};
for (const limit of limits) {
if (!limit || limit.type !== "TOKENS_LIMIT") continue;
const usedPercent = Number(limit.percentage) || 0;
const resetMs = Number(limit.nextResetTime) || 0;
const remaining = Math.max(0, 100 - usedPercent);
quotas["session"] = {
used: usedPercent,
total: 100,
remaining,
remainingPercentage: remaining,
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
unlimited: false,
};
}
const levelRaw = typeof data.level === "string" ? data.level : "";
const plan = levelRaw
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
: "Unknown";
return { plan, quotas };
} catch (error) {
return { message: `GLM error: ${error.message}` };
}
}
/**
* Vercel AI Gateway usage — credit balance for the API key
*
* Calls GET /v1/credits which returns:
* { "balance": "95.50", "total_used": "4.50" } (USD as decimal strings)
*
* We surface this as a single "Balance ($)" quota row so the existing
* QuotaTable / progress-bar UI can render it. used = total_used,
* total = balance + total_used (the original credit allotment), so the
* remaining percentage equals balance / total.
*
* Docs: https://vercel.com/docs/ai-gateway/usage
*/
export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) {
if (!apiKey) {
return { message: "Vercel AI Gateway API key not available." };
}
try {
const response = await proxyAwareFetch(VERCEL_AI_GATEWAY_CREDITS_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
}, proxyOptions);
if (response.status === 401 || response.status === 403) {
return { message: "Vercel AI Gateway API key invalid or expired." };
}
if (!response.ok) {
const errorText = await response.text().catch(() => "");
const trimmed = errorText ? `: ${errorText.slice(0, 200)}` : "";
return { message: `Vercel AI Gateway credits API error (${response.status})${trimmed}` };
}
const data = await response.json();
// Vercel returns numeric strings; coerce safely.
const balance = Number(data?.balance) || 0;
const totalUsed = Number(data?.total_used) || 0;
// Vercel gives $5/month free credit. The API doesn't return the
// monthly allocation so we use the known constant as the denominator.
const MONTHLY_CREDIT = 5;
const remainingPercentage = (balance / MONTHLY_CREDIT) * 100;
if (balance <= 0 && totalUsed <= 0) {
return {
plan: "Pay-as-you-go",
message: "Vercel AI Gateway connected. No credit allocation found (BYOK or unfunded account).",
quotas: {},
};
}
// "Used (USD)": how much has been spent this month (no fixed cap → unlimited).
// "Remaining (USD)": balance remaining out of the $5 monthly allocation.
return {
plan: "Pay-as-you-go",
quotas: {
"Used (USD)": {
used: totalUsed,
total: 0,
remaining: 0,
remainingPercentage: 100,
unlimited: true,
},
"Remaining (USD)": {
used: balance,
total: MONTHLY_CREDIT,
remaining: balance,
remainingPercentage,
unlimited: false,
},
},
};
} catch (error) {
return { message: `Vercel AI Gateway error: ${error.message}` };
}
}
export async function getQoderUsage(accessToken, proxyOptions = null) {
if (!accessToken) {
return { message: "Qoder usage unavailable: no access token" };
}
try {
const response = await proxyAwareFetch(
U("qoder").url,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
},
proxyOptions,
);
if (!response.ok) {
return { message: `Qoder connected. Usage fetch returned ${response.status}.` };
}
const body = await response.json().catch(() => null);
if (!body) {
return { message: "Qoder connected. Usage response was not JSON." };
}
// Quota records live under `quotas`; scalar metadata
// (totalUsagePercentage, isQuotaExceeded, expiresAt) are surfaced as
// siblings so the dashboard parser doesn't try to render them as rows.
const userQuota = body.userQuota || {};
const orgQuota = body.orgResourcePackage || {};
// Qoder publishes a single absolute reset timestamp (`expiresAt` in ms);
// surface it on every quota record as ISO so the table can render
// "resets at" alongside used/total.
const expiresAtMs = Number.isFinite(Number(body.expiresAt)) && Number(body.expiresAt) > 0
? Number(body.expiresAt)
: null;
const resetAt = expiresAtMs ? new Date(expiresAtMs).toISOString() : null;
const quotas = {
user: {
total: Number(userQuota.total) || 0,
used: Number(userQuota.used) || 0,
remaining: Number(userQuota.remaining) || 0,
unit: userQuota.unit || "credits",
resetAt,
},
organization: {
total: Number(orgQuota.total) || 0,
used: Number(orgQuota.used) || 0,
remaining: Number(orgQuota.remaining) || 0,
unit: orgQuota.unit || "credits",
resetAt,
},
};
return {
quotas,
totalUsagePercentage: Number(body.totalUsagePercentage) || 0,
isQuotaExceeded: !!body.isQuotaExceeded,
expiresAt: expiresAtMs,
};
} catch (error) {
return { message: `Qoder connected. Unable to fetch usage: ${error.message}` };
}
}

View File

@@ -0,0 +1,70 @@
/**
* Shared usage helpers (cross-provider)
*/
import { PROVIDERS } from "../../providers/index.js";
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
// usage endpoints: single source from registry transport.usage
export const U = (id) => PROVIDERS[id]?.usage || {};
/**
* Parse reset date/time to ISO string
* Handles multiple formats: Unix timestamp (ms), ISO date string, etc.
*/
export function parseResetTime(resetValue) {
if (!resetValue) return null;
try {
// If it's already a Date object
if (resetValue instanceof Date) {
return resetValue.toISOString();
}
// Unix timestamps from provider APIs may be seconds or milliseconds.
if (typeof resetValue === 'number') {
return new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue).toISOString();
}
// If it's a numeric string, treat it like a Unix timestamp too.
if (typeof resetValue === 'string') {
if (/^\d+$/.test(resetValue)) {
const timestamp = Number(resetValue);
return new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp).toISOString();
}
return new Date(resetValue).toISOString();
}
return null;
} catch (error) {
console.warn(`Failed to parse reset time: ${resetValue}`, error);
return null;
}
}
export function toFiniteNumber(value, fallback = 0) {
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 fallback;
}
export function normalizeCloudCodeProjectId(project) {
if (typeof project === "string") return project.trim() || null;
if (project && typeof project === "object" && typeof project.id === "string") {
return project.id.trim() || null;
}
return null;
}
export async function fetchWithTimeout(url, opts, ms = 10000, proxyOptions = null) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), ms);
try {
return await proxyAwareFetch(url, { ...opts, signal: controller.signal }, proxyOptions);
} finally {
clearTimeout(timeoutId);
}
}