merge: resolve conflicts with origin/master - keep both local and remote features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
31
open-sse/services/tokenRefresh/dedup.js
Normal file
31
open-sse/services/tokenRefresh/dedup.js
Normal 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;
|
||||
}
|
||||
580
open-sse/services/tokenRefresh/providers.js
Normal file
580
open-sse/services/tokenRefresh/providers.js
Normal 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
147
open-sse/services/usage/claude.js
Normal file
147
open-sse/services/usage/claude.js
Normal 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}` };
|
||||
}
|
||||
}
|
||||
131
open-sse/services/usage/codebuddy-cn.js
Normal file
131
open-sse/services/usage/codebuddy-cn.js
Normal 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}` };
|
||||
}
|
||||
}
|
||||
145
open-sse/services/usage/codex.js
Normal file
145
open-sse/services/usage/codex.js
Normal 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,
|
||||
};
|
||||
}
|
||||
100
open-sse/services/usage/github.js
Normal file
100
open-sse/services/usage/github.js
Normal 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,
|
||||
};
|
||||
}
|
||||
243
open-sse/services/usage/google.js
Normal file
243
open-sse/services/usage/google.js
Normal 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;
|
||||
}
|
||||
}
|
||||
183
open-sse/services/usage/kiro.js
Normal file
183
open-sse/services/usage/kiro.js
Normal 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: {},
|
||||
};
|
||||
}
|
||||
234
open-sse/services/usage/minimax.js
Normal file
234
open-sse/services/usage/minimax.js
Normal 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." };
|
||||
}
|
||||
269
open-sse/services/usage/misc.js
Normal file
269
open-sse/services/usage/misc.js
Normal 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}` };
|
||||
}
|
||||
}
|
||||
70
open-sse/services/usage/shared.js
Normal file
70
open-sse/services/usage/shared.js
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user