merge: integrate origin/master (v0.5.50) into gitea/new_feature
- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns from the details-filters feature, adopt origin's stripContinuityFields, customToolNames, cache-inclusive usage accounting, and Responses-API SSE→JSON conversion - Adopt origin's provider usage handlers (codebuddy-intl, qoder creds) and modality detection (audio/video inputs) - Keep requestDetails apiKey column (schema v2) + masked key persistence Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
173
open-sse/services/capacityAdapter.js
Normal file
173
open-sse/services/capacityAdapter.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Capacity Adapter — global fallback pools of models per input-modality capability
|
||||
* (vision / pdf / audioInput / videoInput).
|
||||
*
|
||||
* The pool models are appended as extra fallback candidates behind whatever models
|
||||
* were already going to be tried (a combo's members, or a single target model).
|
||||
* combo.js's existing reorderByCapabilities then floats a capable pool model to the
|
||||
* front only when none of the original models can handle the request — so this
|
||||
* never overrides a combo that already has a member covering the capability.
|
||||
*/
|
||||
import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
|
||||
const CAPABILITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
|
||||
const HARD_CAPS = new Set(CAPABILITY_KEYS);
|
||||
const DEFAULT_FALLBACK_MODEL = "oc/mimo-v2.5-free";
|
||||
|
||||
// Normalize a capability entry to { enabled, roundRobin, models }. Backward-compat:
|
||||
// accept the legacy array form [{model, enabled}] (treated as enabled, fallback).
|
||||
function normalizeCapEntry(entry) {
|
||||
if (Array.isArray(entry)) {
|
||||
return { enabled: true, roundRobin: false, models: entry.map((e) => e?.model || e).filter(Boolean) };
|
||||
}
|
||||
if (entry && typeof entry === "object") {
|
||||
return {
|
||||
enabled: entry.enabled !== false,
|
||||
roundRobin: !!entry.roundRobin,
|
||||
models: Array.isArray(entry.models) ? entry.models.filter(Boolean) : [],
|
||||
};
|
||||
}
|
||||
return { enabled: false, roundRobin: false, models: [] };
|
||||
}
|
||||
|
||||
// Resolve one capability's full config. Enabled pools with no models fall back
|
||||
// to DEFAULT_FALLBACK_MODEL so the toggle is never a no-op.
|
||||
export function getCapacityAdapterConfig(cap, settings) {
|
||||
const entry = normalizeCapEntry(settings?.capacityAdapter?.[cap]);
|
||||
if (entry.enabled && entry.models.length === 0) {
|
||||
return { ...entry, models: [DEFAULT_FALLBACK_MODEL] };
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
// Flatten enabled models across all capability pools, in priority order, deduped.
|
||||
export function getCapacityAdapterModels(settings) {
|
||||
const seen = new Set();
|
||||
const models = [];
|
||||
for (const cap of CAPABILITY_KEYS) {
|
||||
const { enabled, models: pool } = getCapacityAdapterConfig(cap, settings);
|
||||
if (!enabled) continue;
|
||||
for (const m of pool) {
|
||||
if (!seen.has(m)) {
|
||||
seen.add(m);
|
||||
models.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
// Strategy for a capability: "round-robin" when enabled+roundRobin, else "fallback".
|
||||
export function getCapacityAdapterStrategy(cap, settings) {
|
||||
const { enabled, roundRobin } = getCapacityAdapterConfig(cap, settings);
|
||||
return enabled && roundRobin ? "round-robin" : "fallback";
|
||||
}
|
||||
|
||||
// Strategy from the request's required capabilities: picks the first capability
|
||||
// whose adapter pool is enabled and can satisfy a hard requirement.
|
||||
export function getActiveAdapterStrategy(requiredCapabilities, settings) {
|
||||
const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c));
|
||||
for (const cap of hard) {
|
||||
const { enabled, models } = getCapacityAdapterConfig(cap, settings);
|
||||
if (!enabled || models.length === 0) continue;
|
||||
return getCapacityAdapterStrategy(cap, settings);
|
||||
}
|
||||
return "fallback";
|
||||
}
|
||||
|
||||
function modelSatisfies(modelStr, requiredHard) {
|
||||
const slash = modelStr.indexOf("/");
|
||||
const provider = slash > 0 ? modelStr.slice(0, slash) : "";
|
||||
const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
return requiredHard.every((c) => caps[c] === true);
|
||||
}
|
||||
|
||||
// Prepend capacity-adapter models as priority candidates when NONE of the
|
||||
// original models (combo members, or the single target model) can satisfy the
|
||||
// request's required capabilities. Adapter models go FIRST (priority); the
|
||||
// original models follow as fallback. Leaves `models` untouched when the
|
||||
// original list already covers it (combo.js's reorderByCapabilities handles
|
||||
// that case via autoSwitch).
|
||||
export function augmentModelsWithCapacityAdapter(models, requiredCapabilities, settings) {
|
||||
const hard = [...(requiredCapabilities || [])].filter((c) => HARD_CAPS.has(c));
|
||||
if (hard.length === 0 || !Array.isArray(models) || models.length === 0) return models;
|
||||
if (models.some((m) => modelSatisfies(m, hard))) return models;
|
||||
|
||||
const pool = getCapacityAdapterModels(settings).filter((m) => !models.includes(m) && modelSatisfies(m, hard));
|
||||
if (pool.length === 0) return models;
|
||||
return [...pool, ...models];
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4; // rough estimate; avoids pulling in a tokenizer dependency
|
||||
const HEAD_KEEP = 6; // messages after system kept verbatim before dropping the middle
|
||||
|
||||
function blockLength(content) {
|
||||
if (typeof content === "string") return content.length;
|
||||
if (Array.isArray(content)) {
|
||||
return content.reduce((sum, b) => sum + (typeof b?.text === "string" ? b.text.length : 50), 0);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Trim history to fit a (possibly smaller) context window by dropping the MIDDLE.
|
||||
// Preserves: all system/instruction messages (head), and the trailing user run
|
||||
// carrying the media the switch happened for (tail). Older middle turns between
|
||||
// the head instructions and the current turn are dropped first.
|
||||
export function stripHistoryForContext(body, contextWindow) {
|
||||
const key = Array.isArray(body.messages) ? "messages"
|
||||
: Array.isArray(body.input) ? "input"
|
||||
: Array.isArray(body.contents) ? "contents"
|
||||
: null;
|
||||
if (!key) return body;
|
||||
const arr = body[key];
|
||||
if (!arr || arr.length === 0) return body;
|
||||
|
||||
const isSystem = (r) => r === "system" || r === "developer";
|
||||
const systemMsgs = arr.filter((m) => isSystem(m?.role));
|
||||
const rest = arr.filter((m) => !isSystem(m?.role));
|
||||
if (rest.length === 0) return body;
|
||||
|
||||
const isAssistant = (r) => r === "assistant" || r === "model";
|
||||
let i = rest.length - 1;
|
||||
while (i >= 0 && !isAssistant(rest[i]?.role)) i--;
|
||||
const tail = rest.slice(i + 1); // current user turn (has media) — always kept
|
||||
const older = rest.slice(0, i + 1); // everything before it
|
||||
if (older.length === 0) return body;
|
||||
|
||||
const contentOf = (m) => m.content ?? m.parts;
|
||||
// Cap at 80% of the adapter model's context window — leaves room for the response.
|
||||
const budgetChars = (contextWindow || 200000) * 0.8 * CHARS_PER_TOKEN;
|
||||
|
||||
// Prefer keeping the first HEAD_KEEP messages (initial instructions/context) verbatim;
|
||||
// only trim further if even that exceeds the adapter model's context window.
|
||||
const headKept = older.slice(0, HEAD_KEEP);
|
||||
let total = systemMsgs.concat(headKept, tail).reduce((s, m) => s + blockLength(contentOf(m)), 0);
|
||||
|
||||
// If head + tail overflow, drop head turns from the end (closest to middle) first.
|
||||
let head = headKept;
|
||||
while (total > budgetChars && head.length > 0) {
|
||||
const dropped = head.pop();
|
||||
total -= blockLength(contentOf(dropped));
|
||||
}
|
||||
|
||||
if (head.length === older.length) return body;
|
||||
return { ...body, [key]: [...systemMsgs, ...head, ...tail] };
|
||||
}
|
||||
|
||||
// Wrap a handleSingleModel callback so calls to a capacity-adapter model strip
|
||||
// history to fit its context window first. No-op passthrough when the pool is empty.
|
||||
export function withCapacityAdapterStripping(handleSingleModel, adapterModels) {
|
||||
const adapterSet = new Set(adapterModels);
|
||||
if (adapterSet.size === 0) return handleSingleModel;
|
||||
return (body, modelStr, ...rest) => {
|
||||
if (adapterSet.has(modelStr)) {
|
||||
const slash = modelStr.indexOf("/");
|
||||
const provider = slash > 0 ? modelStr.slice(0, slash) : "";
|
||||
const model = slash > 0 ? modelStr.slice(slash + 1) : modelStr;
|
||||
const { contextWindow } = getCapabilitiesForModel(provider, model);
|
||||
body = stripHistoryForContext(body, contextWindow);
|
||||
}
|
||||
return handleSingleModel(body, modelStr, ...rest);
|
||||
};
|
||||
}
|
||||
@@ -126,19 +126,33 @@ 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 addByMime = (mime) => {
|
||||
if (typeof mime !== "string") return;
|
||||
if (mime.startsWith("image/")) required.add("vision");
|
||||
else if (mime === "application/pdf") required.add("pdf");
|
||||
else if (mime.startsWith("audio/")) required.add("audioInput");
|
||||
else if (mime.startsWith("video/")) required.add("videoInput");
|
||||
};
|
||||
|
||||
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 === "input_audio" || t === "audio_url" || t === "audio") required.add("audioInput");
|
||||
if (t === "input_video" || t === "video_url" || t === "video") required.add("videoInput");
|
||||
if (t === "file" || t === "document" || t === "input_file") {
|
||||
// Infer modality from embedded mime when available; fall back to pdf for generic files.
|
||||
let fmime = null;
|
||||
if (b.input_audio?.format) fmime = `audio/${b.input_audio.format}`;
|
||||
else if (b.file?.file_data) fmime = String(b.file.file_data).match(/^data:([^;,]+)/)?.[1];
|
||||
else if (b.source?.media_type) fmime = b.source.media_type;
|
||||
else if (b.source?.data) fmime = String(b.source.data).match(/^data:([^;,]+)/)?.[1];
|
||||
if (fmime) addByMime(fmime);
|
||||
else required.add("pdf");
|
||||
}
|
||||
// gemini parts: inlineData/fileData carry a mime
|
||||
addByMime(b.inlineData?.mimeType || b.fileData?.mimeType);
|
||||
};
|
||||
|
||||
const scanContent = (content) => {
|
||||
if (Array.isArray(content)) for (const b of content) scanBlock(b);
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* This significantly reduces the risk of being flagged by Google's anti-abuse systems.
|
||||
*/
|
||||
|
||||
import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js";
|
||||
import { CLOUD_CODE_API, LOAD_CODE_ASSIST_HEADERS, ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_METADATA } from "../config/appConstants.js";
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
// connectionId -> { projectId: string, fetchedAt: number }
|
||||
@@ -157,9 +157,10 @@ export function removeConnection(connectionId) {
|
||||
*/
|
||||
async function fetchProjectId(accessToken, signal, provider) {
|
||||
const endpoints = CLOUD_CODE_API[provider] || CLOUD_CODE_API["gemini-cli"];
|
||||
const headers = provider === "antigravity" ? ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS : LOAD_CODE_ASSIST_HEADERS;
|
||||
const response = await fetch(endpoints.loadCodeAssist, {
|
||||
method: "POST",
|
||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||
headers: { ...headers, "Authorization": `Bearer ${accessToken}` },
|
||||
body: JSON.stringify({ metadata: LOAD_CODE_ASSIST_METADATA }),
|
||||
signal
|
||||
});
|
||||
@@ -186,7 +187,7 @@ async function fetchProjectId(accessToken, signal, provider) {
|
||||
}
|
||||
}
|
||||
|
||||
return onboardUser(accessToken, tierID, signal, endpoints);
|
||||
return onboardUser(accessToken, tierID, signal, endpoints, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,10 +198,11 @@ async function fetchProjectId(accessToken, signal, provider) {
|
||||
* @param {AbortSignal} externalSignal – propagated from the connection's AbortController
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function onboardUser(accessToken, tierID, externalSignal, endpoints) {
|
||||
async function onboardUser(accessToken, tierID, externalSignal, endpoints, provider) {
|
||||
console.log(`[ProjectId] Onboarding user with tier: ${tierID}`);
|
||||
|
||||
const reqBody = { tierId: tierID, metadata: LOAD_CODE_ASSIST_METADATA };
|
||||
const headers = provider === "antigravity" ? ANTIGRAVITY_LOAD_CODE_ASSIST_HEADERS : LOAD_CODE_ASSIST_HEADERS;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
@@ -216,7 +218,7 @@ async function onboardUser(accessToken, tierID, externalSignal, endpoints) {
|
||||
try {
|
||||
const response = await fetch(endpoints.onboardUser, {
|
||||
method: "POST",
|
||||
headers: { ...LOAD_CODE_ASSIST_HEADERS, "Authorization": `Bearer ${accessToken}` },
|
||||
headers: { ...headers, "Authorization": `Bearer ${accessToken}` },
|
||||
body: JSON.stringify(reqBody),
|
||||
signal: localCtrl.signal
|
||||
});
|
||||
|
||||
@@ -19,9 +19,15 @@ function isAnthropicCompatible(provider) {
|
||||
return typeof provider === "string" && provider.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
|
||||
}
|
||||
|
||||
function getOpenAICompatibleType(provider) {
|
||||
if (!isOpenAICompatible(provider)) return "chat";
|
||||
return provider.includes("responses") ? "responses" : "chat";
|
||||
// Resolve the API type (chat vs responses) for an openai-compatible node.
|
||||
// The stored apiType on the connection's providerSpecificData (kept in sync with
|
||||
// the node on create/update) is authoritative. Falls back to the node ID
|
||||
// substring for legacy nodes created before apiType was persisted — their IDs
|
||||
// embed the type: openai-compatible-<chat|responses>-<uuid>.
|
||||
export function resolveOpenAICompatibleApiType(provider, credentials = null) {
|
||||
const stored = credentials?.providerSpecificData?.apiType;
|
||||
if (stored === "chat" || stored === "responses") return stored;
|
||||
return typeof provider === "string" && provider.includes("responses") ? "responses" : "chat";
|
||||
}
|
||||
|
||||
// Detect request format from body structure
|
||||
@@ -105,9 +111,9 @@ export function detectFormat(body) {
|
||||
}
|
||||
|
||||
// Get provider config (internal — no external runtime consumer)
|
||||
function getProviderConfig(provider) {
|
||||
function getProviderConfig(provider, credentials = null) {
|
||||
if (isOpenAICompatible(provider)) {
|
||||
const apiType = getOpenAICompatibleType(provider);
|
||||
const apiType = resolveOpenAICompatibleApiType(provider, credentials);
|
||||
return {
|
||||
...PROVIDERS.openai,
|
||||
format: apiType === "responses" ? "openai-responses" : "openai",
|
||||
@@ -125,14 +131,14 @@ function getProviderConfig(provider) {
|
||||
}
|
||||
|
||||
// Get target format for provider
|
||||
export function getTargetFormat(provider) {
|
||||
export function getTargetFormat(provider, credentials = null) {
|
||||
if (isOpenAICompatible(provider)) {
|
||||
return getOpenAICompatibleType(provider) === "responses" ? "openai-responses" : "openai";
|
||||
return resolveOpenAICompatibleApiType(provider, credentials) === "responses" ? "openai-responses" : "openai";
|
||||
}
|
||||
if (isAnthropicCompatible(provider)) {
|
||||
return "claude";
|
||||
}
|
||||
const config = getProviderConfig(provider);
|
||||
const config = getProviderConfig(provider, credentials);
|
||||
return config.format || "openai";
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
*
|
||||
* On any error the live cache stays empty and chatExecuteCall surfaces the
|
||||
* problem to the user as "model config not yet fetched, retry shortly".
|
||||
*
|
||||
* PAT (Personal Access Token, pt-...) connections: a PAT cannot sign COSY
|
||||
* requests directly, so we exchange it for a short-lived job token (jt-...)
|
||||
* via openapi.qoder.sh/api/v1/jobToken/exchange (plain JSON POST), then use
|
||||
* that job token for signing. Job-token traffic must hit api2.qoder.sh —
|
||||
* api3 rejects jt- with "Login expired" (403).
|
||||
*/
|
||||
|
||||
import { createHash } from "crypto";
|
||||
@@ -18,11 +24,30 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { buildCosyHeaders } from "../shared/qoder/cosy.js";
|
||||
import {
|
||||
QODER_MODEL_LIST_URL,
|
||||
QODER_CHAT_BASE_ALT,
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
QODER_USERINFO_URL,
|
||||
QODER_IDE_VERSION,
|
||||
QODER_CLIENT_TYPE,
|
||||
} from "../shared/qoder/constants.js";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h, same as the Kiro catalog
|
||||
|
||||
const PAT_PREFIX = "pt-";
|
||||
|
||||
// PAT → job-token cache: a job token is short-lived (24h), so we keep it per
|
||||
// PAT and re-exchange once it is within 5 minutes of expiry.
|
||||
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
const PAT_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function isQoderPat(token) {
|
||||
return typeof token === "string" && token.startsWith(PAT_PREFIX);
|
||||
}
|
||||
|
||||
/** @type {Map<string, { accessToken: string, userId: string, expiresAt: number }>} */
|
||||
const patJobCache = new Map();
|
||||
|
||||
/** @type {Map<string, { expiresAt: number, models: any[], rawConfigs: Map<string, object>, fetched: boolean }>} */
|
||||
const catalogCache = new Map();
|
||||
|
||||
@@ -34,6 +59,109 @@ const catalogCache = new Map();
|
||||
*/
|
||||
const inflight = new Map();
|
||||
|
||||
/**
|
||||
* Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
|
||||
* This endpoint is plain JSON POST — NOT COSY-signed.
|
||||
*/
|
||||
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "qodercli/1.0.0",
|
||||
"Cosy-Version": QODER_IDE_VERSION,
|
||||
"Cosy-ClientType": QODER_CLIENT_TYPE,
|
||||
},
|
||||
body: JSON.stringify({ personal_token: pat }),
|
||||
signal,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.token) throw new Error("qoder PAT exchange returned no job token");
|
||||
|
||||
let expiresAt = Date.now() + PAT_DEFAULT_TTL_MS;
|
||||
if (data.expires_at) {
|
||||
const parsed = Date.parse(data.expires_at);
|
||||
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
||||
} else if (typeof data.expires_in === "number" && data.expires_in > 0) {
|
||||
expiresAt = Date.now() + data.expires_in;
|
||||
}
|
||||
return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Qoder userId for a job token (needed for COSY signing).
|
||||
* Returns "" on any failure — callers fall back to the stored userId.
|
||||
*/
|
||||
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_USERINFO_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jobToken}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "qodercli/1.0.0",
|
||||
},
|
||||
signal,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) return "";
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return data.id || data.userId || data.user_id || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a PAT to a job-token credential, cached per-PAT.
|
||||
*/
|
||||
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
||||
const cached = patJobCache.get(pat);
|
||||
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) return cached;
|
||||
|
||||
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
|
||||
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
|
||||
const resolved = { accessToken: jobToken, userId, expiresAt };
|
||||
patJobCache.set(pat, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve connection credentials to COSY-signable form:
|
||||
* - PAT (pt-...) connections → exchanged to a job token (jt-...) + userId
|
||||
* - everything else → passed through unchanged
|
||||
*/
|
||||
export async function resolveQoderCredentials(credentials, proxyOptions = null, signal = null) {
|
||||
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||
if (isQoderPat(raw)) {
|
||||
const resolved = await resolvePatCredential(raw, proxyOptions, signal);
|
||||
return {
|
||||
...credentials,
|
||||
accessToken: resolved.accessToken,
|
||||
apiKey: undefined,
|
||||
providerSpecificData: {
|
||||
authMethod: "pat",
|
||||
...(credentials?.providerSpecificData || {}),
|
||||
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
|
||||
machineId: credentials?.providerSpecificData?.machineId || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key per credential (so different login sessions for the same
|
||||
* account share an entry).
|
||||
@@ -68,10 +196,16 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) {
|
||||
const creds = cosyCredsFromConnection(credentials);
|
||||
if (!creds.userId || !creds.authToken) return null;
|
||||
|
||||
// Job-token traffic is rejected by api3 ("Login expired" 403) — the
|
||||
// official qodercli serves it from api2 instead.
|
||||
const modelListUrl = String(creds.authToken).startsWith("jt-")
|
||||
? `${QODER_CHAT_BASE_ALT}/algo/api/v2/model/list`
|
||||
: QODER_MODEL_LIST_URL;
|
||||
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
"Accept-Encoding": "identity",
|
||||
...buildCosyHeaders(Buffer.alloc(0), QODER_MODEL_LIST_URL, creds),
|
||||
...buildCosyHeaders(Buffer.alloc(0), modelListUrl, creds),
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -92,7 +226,7 @@ async function fetchQoderCatalogRaw(credentials, signal, proxyOptions = null) {
|
||||
}
|
||||
}
|
||||
response = await proxyAwareFetch(
|
||||
QODER_MODEL_LIST_URL,
|
||||
modelListUrl,
|
||||
{
|
||||
method: "GET",
|
||||
headers,
|
||||
@@ -159,11 +293,16 @@ export async function getQoderModelConfig(credentials, modelKey, options = {}) {
|
||||
* one upstream request per credential.
|
||||
*/
|
||||
export async function resolveQoderModels(credentials, options = {}) {
|
||||
if (!credentials?.accessToken) return null;
|
||||
const psd = credentials.providerSpecificData || {};
|
||||
if (!psd.userId) return null;
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await resolveQoderCredentials(credentials, options.proxyOptions, options.signal);
|
||||
} catch (error) {
|
||||
options.log?.warn?.("QODER", `PAT exchange failed: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
if (!resolved?.accessToken || !(resolved.providerSpecificData || {}).userId) return null;
|
||||
|
||||
const key = cacheKey(credentials);
|
||||
const key = cacheKey(resolved);
|
||||
const now = Date.now();
|
||||
if (!options.forceRefresh) {
|
||||
const cached = catalogCache.get(key);
|
||||
@@ -180,7 +319,7 @@ export async function resolveQoderModels(credentials, options = {}) {
|
||||
}
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
const fetched = await fetchQoderCatalogRaw(credentials, options.signal, options.proxyOptions);
|
||||
const fetched = await fetchQoderCatalogRaw(resolved, options.signal, options.proxyOptions);
|
||||
if (!fetched) return null;
|
||||
const entry = {
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
refreshKimiToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
@@ -26,7 +25,6 @@ export {
|
||||
refreshKimiToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
@@ -137,7 +135,6 @@ const REFRESH_HANDLERS = {
|
||||
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),
|
||||
@@ -205,7 +202,6 @@ export function formatProviderCredentials(provider, credentials, log) {
|
||||
};
|
||||
|
||||
case "codex":
|
||||
case "qwen":
|
||||
case "iflow":
|
||||
case "openai":
|
||||
case "openrouter":
|
||||
|
||||
@@ -40,11 +40,6 @@ const REFRESH_PROFILES = {
|
||||
url: () => OAUTH_ENDPOINTS.anthropic.token,
|
||||
dedupKey: "claude",
|
||||
},
|
||||
qwen: {
|
||||
url: () => OAUTH_ENDPOINTS.qwen.token,
|
||||
dedupKey: "qwen",
|
||||
parse: (tokens) => tokens.resource_url ? { providerSpecificData: { resourceUrl: tokens.resource_url } } : {},
|
||||
},
|
||||
iflow: {
|
||||
url: () => OAUTH_ENDPOINTS.iflow.token,
|
||||
dedupKey: "iflow",
|
||||
@@ -191,11 +186,6 @@ export async function refreshGoogleToken(refreshToken, clientId, clientSecret, l
|
||||
}, log);
|
||||
}
|
||||
|
||||
// Qwen: form body + clientId, surfaces resource_url. Delegate to refreshAccessToken("qwen", ...).
|
||||
export async function refreshQwenToken(refreshToken, log) {
|
||||
return refreshAccessToken("qwen", refreshToken, {}, log);
|
||||
}
|
||||
|
||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||
let parsed = null;
|
||||
try {
|
||||
|
||||
@@ -10,14 +10,14 @@ import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitReset
|
||||
export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits };
|
||||
import { getKiroUsage } from "./usage/kiro.js";
|
||||
import { getMiniMaxUsage } from "./usage/minimax.js";
|
||||
import { getCodeBuddyCnUsage } from "./usage/codebuddy-cn.js";
|
||||
import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn.js";
|
||||
import { getXaiUsage } from "./usage/xai.js";
|
||||
import { getGrokCliUsage } from "./usage/grok-cli.js";
|
||||
import { getKimiUsage } from "./usage/kimi.js";
|
||||
import { getDeepseekUsage } from "./usage/deepseek.js";
|
||||
import { getCommandCodeUsage } from "./usage/commandcode.js";
|
||||
import { resolveQoderCredentials } from "./qoderModels.js";
|
||||
import {
|
||||
getQwenUsage,
|
||||
getIflowUsage,
|
||||
getOllamaUsage,
|
||||
getGlmUsage,
|
||||
@@ -38,10 +38,14 @@ const USAGE_HANDLERS = {
|
||||
claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions),
|
||||
codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions),
|
||||
kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
qoder: (c) => getQoderUsage(c.accessToken, c.proxyOptions),
|
||||
qwen: (c) => getQwenUsage(c.accessToken, c.providerSpecificData),
|
||||
qoder: async (c) => {
|
||||
// PAT (pt-...) connections must be exchanged to a job token before the
|
||||
// quota endpoint accepts them.
|
||||
const resolved = await resolveQoderCredentials(c, c.proxyOptions).catch(() => null);
|
||||
return getQoderUsage(resolved?.accessToken || c.accessToken, c.proxyOptions);
|
||||
},
|
||||
iflow: (c) => getIflowUsage(c.accessToken),
|
||||
ollama: (c) => getOllamaUsage(c.accessToken),
|
||||
ollama: (c) => getOllamaUsage(c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
glm: (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||
"glm-cn": (c) => getGlmUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||
minimax: (c) => getMiniMaxUsage(c.apiKey, c.provider, c.proxyOptions),
|
||||
@@ -49,6 +53,7 @@ const USAGE_HANDLERS = {
|
||||
"vercel-ai-gateway": (c) => getVercelAiGatewayUsage(c.apiKey, c.proxyOptions),
|
||||
"codebuddy-cn": (c) => getCodeBuddyCnUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
xai: (c) => getXaiUsage(c.accessToken, c.proxyOptions),
|
||||
"codebuddy-intl": (c) => getCodeBuddyIntlUsage(c.accessToken, c.apiKey, c.providerSpecificData, c.proxyOptions),
|
||||
"grok-cli": (c) => getGrokCliUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
|
||||
kimi: (c) => getKimiUsage(c.accessToken, c.apiKey, c.proxyOptions, c.providerSpecificData),
|
||||
deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions),
|
||||
|
||||
@@ -43,17 +43,17 @@ function refillCadence(acc) {
|
||||
return "Monthly";
|
||||
}
|
||||
|
||||
export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
async function getCodeBuddyUsage(providerId, accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
return { message: "CodeBuddy CN credential not available." };
|
||||
return { message: `CodeBuddy (${providerId}) credential not available.` };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(U(PROVIDER_ID).url, {
|
||||
const response = await proxyAwareFetch(U(providerId).url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(PROVIDERS[PROVIDER_ID]?.headers || {}),
|
||||
...(PROVIDERS[providerId]?.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
@@ -129,10 +129,18 @@ export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificD
|
||||
});
|
||||
|
||||
const basePkg = refills[0] || accounts[0] || {};
|
||||
const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN";
|
||||
const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy";
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `CodeBuddy CN error: ${error.message}` };
|
||||
return { message: `CodeBuddy (${providerId}) error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
return getCodeBuddyUsage(PROVIDER_ID, accessToken, apiKey, providerSpecificData, proxyOptions);
|
||||
}
|
||||
|
||||
export async function getCodeBuddyIntlUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
return getCodeBuddyUsage("codebuddy-intl", accessToken, apiKey, providerSpecificData, proxyOptions);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
if (data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3-flash-agent',
|
||||
'gemini-3.6-flash-high',
|
||||
'gemini-3.6-flash-medium',
|
||||
'gemini-3.6-flash-low',
|
||||
'gemini-3.5-flash-low',
|
||||
'gemini-3.5-flash-extra-low',
|
||||
'gemini-pro-agent',
|
||||
@@ -169,10 +171,8 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
'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)) {
|
||||
|
||||
@@ -91,6 +91,24 @@ function resolvePlan(user, config) {
|
||||
return "Grok Build";
|
||||
}
|
||||
|
||||
// Display only; upstream remains authoritative for access and quota enforcement.
|
||||
function planFromAccessToken(accessToken) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url"));
|
||||
return {
|
||||
0: "Free",
|
||||
1: "SuperGrok",
|
||||
2: "X Basic",
|
||||
3: "X Premium",
|
||||
4: "X Premium Plus",
|
||||
5: "SuperGrok Heavy",
|
||||
6: "SuperGrok Lite",
|
||||
}[payload.tier] || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function makeQuota({ used, total, resetAt, unlimited = false }) {
|
||||
const safeTotal = Math.max(0, toFiniteNumber(total, 0));
|
||||
const safeUsed = Math.max(0, toFiniteNumber(used, 0));
|
||||
@@ -371,6 +389,7 @@ export async function getGrokCliUsage(accessToken, providerSpecificData = null,
|
||||
}
|
||||
|
||||
const parsed = parseGrokCliBilling(billing, user);
|
||||
parsed.plan = planFromAccessToken(accessToken) || parsed.plan;
|
||||
|
||||
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
|
||||
// Paid SuperGrok often returns cap=0 over REST but exposes the shared
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
@@ -15,23 +15,6 @@ const GLM_QUOTA_URLS = {
|
||||
// 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
|
||||
*/
|
||||
@@ -46,23 +29,86 @@ export async function getIflowUsage(accessToken) {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1
|
||||
* ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed.
|
||||
* POST https://ollama.com/api/me — plan label (fail-open).
|
||||
* Auth: Authorization: Bearer <apiKey>
|
||||
*/
|
||||
export async function getOllamaUsage(accessToken, providerSpecificData) {
|
||||
export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "Ollama Cloud API key not available." };
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
const response = await proxyAwareFetch("https://ollama.com/api/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { message: "Ollama Cloud API key invalid or expired." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { message: `Ollama Cloud usage API error (${response.status}).` };
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
return { message: "Ollama Cloud usage response was not JSON." };
|
||||
}
|
||||
|
||||
// Best-effort plan label from /api/me
|
||||
const me = await proxyAwareFetch("https://ollama.com/api/me", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Length": "0",
|
||||
},
|
||||
}, proxyOptions).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
||||
|
||||
const planRaw = typeof me?.Plan === "string" ? me.Plan : "";
|
||||
const plan = planRaw
|
||||
? planRaw.charAt(0).toUpperCase() + planRaw.slice(1).toLowerCase()
|
||||
: "Ollama Cloud";
|
||||
|
||||
const limits = data?.limits && typeof data.limits === "object" ? data.limits : {};
|
||||
|
||||
// Ollama `usage` is a 0..1 ratio (1.0 = limit reached). Convert to a 0..100
|
||||
// bar. Do NOT set absolute `remaining` — QuotaTable reads remainingPercentage.
|
||||
function ratioQuota(usageRatio, resetAt = null) {
|
||||
const ratio = Math.max(0, Math.min(1, Number(usageRatio) || 0));
|
||||
const usedPct = Math.round(ratio * 100);
|
||||
return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false };
|
||||
}
|
||||
|
||||
const sessionRaw = limits.session?.usage;
|
||||
const weeklyRaw = limits.weekly?.usage;
|
||||
const sessionNum = Number(sessionRaw);
|
||||
const weeklyNum = Number(weeklyRaw);
|
||||
const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum);
|
||||
const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum);
|
||||
|
||||
if (!hasSession && !hasWeekly) {
|
||||
return {
|
||||
plan,
|
||||
message: "Ollama Cloud connected. No usage limits reported.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum);
|
||||
if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum);
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Ollama Cloud usage." };
|
||||
return { message: `Ollama Cloud error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user