Merge origin/master (v0.5.81) into gitea/new_feature

Resolve conflicts:
- streamingHandler.js: merge buildStreamErrorBytes onAbortTerminal + local shouldPersistRequestDetail & streamStatusForContent
- capabilities.js: preserve server-injected user-asserted caps and models.dev catalog lookup; wire CommandCode /alpha/generate caps inside resolve()
- commandcode.js (services/usage): adopt upstream whoami + billing credits/subscriptions with 5h/weekly rate windows and plan caps
- openai-to-commandcode.js: merge toNativeImageBlock (data-URI & http(s) support) and assistant reasoning_content preservation
- commandcode-to-openai.js: adopt upstream mid-stream error throw for clean retry and abortion
- tests: sync commandcode test suite and exclude .next from vitest config
This commit is contained in:
2026-09-22 10:08:58 +07:00
95 changed files with 4059 additions and 1016 deletions

View File

@@ -29,7 +29,7 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
// Request-scoped rule: the request body itself is at fault — no cooldown,
// no account lock. Caller must stop rotating and surface the error.
if (rule.requestScoped && lowerError && lowerError.includes(rule.text)) {
return { shouldFallback: false, requestScoped: true, cooldownMs: 0 };
return { shouldFallback: false, cooldownMs: 0 };
}
// Text-based rule: match substring in error message
@@ -51,6 +51,20 @@ export function checkFallbackError(status, errorText, backoffLevel = 0) {
}
}
// Request-scoped client errors that matched no rule above: a 400 caused by the
// request itself (context overflow, malformed body, unsupported parameter) says
// nothing about the credential, so cooling the account down only removes a
// healthy connection from rotation. With a single connection it is worse: every
// later request in the window fails with a copy of this very error
// ("all 1 accounts locked for <model> | lastError=[400]: ..."), which hides the
// real cause from the caller and makes unrelated sessions look like they hit the
// same limit. Hand the upstream error back for this request instead.
// Account-scoped statuses keep their rules above (401/402/403/404/429), and the
// text rules still win for rate-limit / quota / capacity wording.
if (status >= 400 && status < 500 && status !== 401 && status !== 402 && status !== 403 && status !== 429) {
return { shouldFallback: false, cooldownMs: 0 };
}
// Default: transient cooldown for any unmatched error
return { shouldFallback: true, cooldownMs: TRANSIENT_COOLDOWN_MS };
}

View File

@@ -124,6 +124,8 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
// Config-driven prefix → provider inference (first match wins, fallback "openai").
const MODEL_PREFIX_PROVIDERS = [
// Codex CLI sends this bare virtual model for auto-review — keep it on OAuth Codex (#1398).
[/^codex-auto-review$/, "codex"],
[/^claude-/, "anthropic"],
[/^gemini-/, "gemini"],
[/^gpt-/, "openai"],

View File

@@ -10,6 +10,24 @@ const signatureKv = makeKv(SCOPE);
const memorySignatures = new Map();
let pruneCounter = 0;
/**
* Model family that produced / will consume a signature. Antigravity serves Gemini and Claude
* models behind the same API, and each backend only accepts its own signatures: a Claude
* signature replayed to Gemini fails with 400 "Corrupted thought signature." (and vice versa).
*/
export function signatureFamily(model) {
const m = typeof model === "string" ? model.toLowerCase() : "";
if (!m) return null;
if (m.includes("claude")) return "claude";
if (m.includes("gemini")) return "gemini";
return m;
}
// Entries stored before families were recorded (no `family`) stay usable for any model.
function isCompatible(entry, family) {
return !entry.family || !family || entry.family === family;
}
function pruneMemoryExpired() {
const now = Date.now();
for (const [key, value] of memorySignatures.entries()) {
@@ -62,13 +80,15 @@ async function maybePrunePersisted() {
}
/**
* Store a thought signature for a tool_call_id with optional sessionId namespace (RAM + SQLite async)
* Store a thought signature for a tool_call_id with optional sessionId namespace (RAM + SQLite async).
* `model` is the model that produced the signature; lookups for another model family skip it.
*/
export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = null) {
export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = null, model = null) {
if (typeof toolCallId !== "string" || !toolCallId) return;
if (typeof signature !== "string" || !signature) return;
const now = Date.now();
const family = signatureFamily(model);
pruneMemoryExpired();
const keys = [];
@@ -80,12 +100,14 @@ export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = n
for (const k of keys) {
memorySignatures.set(k, {
signature,
family,
expiresAt: now + MEMORY_TTL_MS,
});
// Async persist to SQLite kv table without blocking
signatureKv.set(k, {
signature,
family,
createdAt: now,
expiresAt: now + PERSISTED_TTL_MS,
}).catch(() => {});
@@ -95,23 +117,25 @@ export function storeGeminiThoughtSignature(toolCallId, signature, sessionId = n
}
/**
* Retrieve a thought signature by tool_call_id (RAM first, then SQLite fallback)
* Retrieve a thought signature by tool_call_id (RAM first, then SQLite fallback).
* `model` is the target model; signatures produced by another model family are ignored.
*/
export async function getGeminiThoughtSignature(toolCallId, sessionId = null) {
export async function getGeminiThoughtSignature(toolCallId, sessionId = null, model = null) {
if (typeof toolCallId !== "string" || !toolCallId) return null;
const family = signatureFamily(model);
pruneMemoryExpired();
if (sessionId && typeof sessionId === "string") {
const sessionKey = `${sessionId}:${toolCallId}`;
const sessionEntry = memorySignatures.get(sessionKey);
if (sessionEntry && sessionEntry.expiresAt > Date.now()) {
if (sessionEntry && sessionEntry.expiresAt > Date.now() && isCompatible(sessionEntry, family)) {
return sessionEntry.signature;
}
}
const entry = memorySignatures.get(toolCallId);
if (entry && entry.expiresAt > Date.now()) {
if (entry && entry.expiresAt > Date.now() && isCompatible(entry, family)) {
return entry.signature;
}
@@ -119,9 +143,10 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) {
if (sessionId && typeof sessionId === "string") {
const sessionKey = `${sessionId}:${toolCallId}`;
const sessionRow = await signatureKv.get(sessionKey);
if (sessionRow && typeof sessionRow.signature === "string" && (!sessionRow.expiresAt || sessionRow.expiresAt > Date.now())) {
if (sessionRow && typeof sessionRow.signature === "string" && (!sessionRow.expiresAt || sessionRow.expiresAt > Date.now()) && isCompatible(sessionRow, family)) {
memorySignatures.set(sessionKey, {
signature: sessionRow.signature,
family: sessionRow.family || null,
expiresAt: Date.now() + MEMORY_TTL_MS,
});
return sessionRow.signature;
@@ -134,8 +159,10 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) {
signatureKv.remove(toolCallId).catch(() => {});
return null;
}
if (!isCompatible(row, family)) return null;
memorySignatures.set(toolCallId, {
signature: row.signature,
family: row.family || null,
expiresAt: Date.now() + MEMORY_TTL_MS,
});
return row.signature;
@@ -148,22 +175,24 @@ export async function getGeminiThoughtSignature(toolCallId, sessionId = null) {
}
/**
* Synchronous get from RAM cache only (for sync translators)
* Synchronous get from RAM cache only (for sync translators).
* `model` is the target model; signatures produced by another model family are ignored.
*/
export function getGeminiThoughtSignatureSync(toolCallId, sessionId = null) {
export function getGeminiThoughtSignatureSync(toolCallId, sessionId = null, model = null) {
if (typeof toolCallId !== "string" || !toolCallId) return null;
const family = signatureFamily(model);
pruneMemoryExpired();
if (sessionId && typeof sessionId === "string") {
const sessionKey = `${sessionId}:${toolCallId}`;
const sessionEntry = memorySignatures.get(sessionKey);
if (sessionEntry && sessionEntry.expiresAt > Date.now()) {
if (sessionEntry && sessionEntry.expiresAt > Date.now() && isCompatible(sessionEntry, family)) {
return sessionEntry.signature;
}
}
const entry = memorySignatures.get(toolCallId);
if (entry && entry.expiresAt > Date.now()) {
if (entry && entry.expiresAt > Date.now() && isCompatible(entry, family)) {
return entry.signature;
}
return null;

View File

@@ -22,6 +22,7 @@ import { getZedUsage } from "./usage/zed.js";
import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.js";
import { resolveQoderCredentials } from "./qoderModels.js";
import { getGlmUsage } from "./usage/glm.js";
import { getCommandCodeUsage } from "./usage/commandcode.js";
import {
getIflowUsage,
getOllamaUsage,
@@ -66,6 +67,7 @@ const USAGE_HANDLERS = {
groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions),
zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
"xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions),
commandcode: (c) => getCommandCodeUsage(c.apiKey, c.proxyOptions),
};
export async function getUsageForProvider(connection, proxyOptions = null, options = {}) {

View File

@@ -1,207 +1,134 @@
/**
* CommandCode usage handler
*
* Mirrors the official command-code CLI /usage command: it calls the alpha API
* to surface the 5-hour + weekly usage windows, the subscription plan, and the
* credits consumed in the current billing period.
*
* GET /alpha/whoami → org.id (org-scoped billing; null for personal)
* GET /alpha/billing/credits → { credits: { monthlyCredits, purchasedCredits,
* freeCredits }, windowLimits: { fiveHour, weekly } }
* GET /alpha/billing/subscriptions → { data: { planId, currentPeriodStart, ... } }
* GET /alpha/usage/summary?since= → period token/cost totals
*
* The CLI fetches whoami first (for orgId), then credits + subscription in
* parallel, then the summary with since = currentPeriodStart. We keep the same
* order/dependencies: window limits live on credits, and the plan period start
* determines the summary window.
* Command Code usage — billing credits + 5h/weekly rate windows.
* Mirrors ~/cc-usage.mjs: whoami → credits + subscriptions.
*/
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
import { U, parseResetTime } from "./shared.js";
import { parseResetTime, toFiniteNumber } from "./shared.js";
const USAGE = U("commandcode");
const BASE = USAGE.baseUrl || "https://api.commandcode.ai";
const WHOAMI_URL = BASE + (USAGE.whoamiUrl || "/alpha/whoami");
const CREDITS_URL = BASE + (USAGE.creditsUrl || "/alpha/billing/credits");
const SUBSCRIPTIONS_URL =
BASE + (USAGE.subscriptionsUrl || "/alpha/billing/subscriptions");
const SUMMARY_URL = BASE + (USAGE.summaryUrl || "/alpha/usage/summary");
const BASE = (process.env.COMMAND_CODE_API_BASE_URL || "https://api.commandcode.ai").replace(/\/$/, "");
function buildHeaders(token) {
return {
Authorization: `Bearer ${token}`,
Accept: "application/json",
};
const PLAN_NAMES = {
"individual-go": "Go",
"individual-goat": "GOAT",
"individual-pro": "Pro",
"individual-pro-v1": "Pro",
"individual-provider": "Provider",
"individual-max": "Max",
"individual-ultra": "Ultra",
"teams-pro": "Teams Pro",
};
const PLAN_CAPS = {
"individual-go": 10,
"individual-goat": 70,
"individual-pro": 30,
"individual-pro-v1": 80,
"individual-provider": 15,
"individual-max": 150,
"individual-ultra": 300,
"teams-pro": 40,
};
function qs(route, params) {
const s = new URLSearchParams(
Object.entries(params || {}).filter(([, v]) => v != null),
).toString();
return s ? `${route}?${s}` : route;
}
/** Build a normalized quota row. `unit` is "$" — the API reports currency credits. */
function makeQuota({ used, total, resetAt, unlimited = false, unit = "$" }) {
const safeTotal = Math.max(0, Number(total) || 0);
const safeUsed = Math.max(0, Number(used) || 0);
if (unlimited || safeTotal === 0) {
return {
used: safeUsed,
total: 0,
remainingPercentage: unlimited ? 100 : 0,
resetAt: resetAt || null,
unit,
unlimited: true,
};
}
const remaining = Math.max(0, safeTotal - safeUsed);
const remainingPercentage = (remaining / safeTotal) * 100;
return {
used: safeUsed,
total: safeTotal,
remainingPercentage,
resetAt: resetAt || null,
unit,
unlimited: false,
};
function windowQuota(win) {
if (!win || typeof win !== "object") return null;
const used = toFiniteNumber(win.used, 0);
const total = toFiniteNumber(win.cap, 0);
if (total <= 0 && used <= 0) return null;
return {
used,
total,
remaining: Math.max(0, total - used),
unlimited: false,
resetAt: parseResetTime(win.resetAt),
};
}
/**
* @param {string} apiKey - commandcode API key (user_...)
* @param {string|null|undefined} apiKey
* @param {object|null} proxyOptions
*/
export async function getCommandCodeUsage(apiKey, proxyOptions = null) {
if (!apiKey) {
return { message: "CommandCode credential not available." };
}
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
return { message: "Command Code API key not available. Add a key to view usage." };
}
const headers = buildHeaders(apiKey);
const headers = {
Authorization: `Bearer ${apiKey.trim()}`,
Accept: "application/json",
};
try {
// whoami resolves the org id (billing is org-scoped; null for personal).
const whoamiRes = await proxyAwareFetch(
WHOAMI_URL,
{ method: "GET", headers },
proxyOptions,
);
if (whoamiRes.status === 401 || whoamiRes.status === 403) {
return { message: "CommandCode credential invalid or expired." };
}
if (!whoamiRes.ok) {
return { message: `CommandCode whoami API error (${whoamiRes.status}).` };
}
const whoami = await whoamiRes.json().catch(() => null);
const orgId = whoami?.org?.id ?? null;
const get = async (route) => {
const response = await proxyAwareFetch(
BASE + route,
{ method: "GET", headers },
proxyOptions,
);
return response;
};
const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
try {
const whoamiRes = await get(qs("/alpha/whoami", { limits: "1" }));
if (whoamiRes.status === 401 || whoamiRes.status === 403) {
return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." };
}
if (!whoamiRes.ok) {
return { plan: "Command Code", message: `Command Code usage API error (${whoamiRes.status})` };
}
const whoami = await whoamiRes.json().catch(() => ({}));
const orgId = whoami?.org?.id ?? null;
const [creditsRes, subsRes] = await Promise.all([
proxyAwareFetch(
CREDITS_URL + orgQuery,
{ method: "GET", headers },
proxyOptions,
),
proxyAwareFetch(
SUBSCRIPTIONS_URL + orgQuery,
{ method: "GET", headers },
proxyOptions,
),
]);
const [creditsRes, subsRes] = await Promise.all([
get(qs("/alpha/billing/credits", { orgId })),
get(qs("/alpha/billing/subscriptions", { orgId })),
]);
if (
creditsRes.status === 401 ||
creditsRes.status === 403 ||
subsRes.status === 401 ||
subsRes.status === 403
) {
return { message: "CommandCode credential invalid or expired." };
}
if (!creditsRes.ok) {
return {
message: `CommandCode credits API error (${creditsRes.status}).`,
};
}
if (creditsRes.status === 401 || creditsRes.status === 403 || subsRes.status === 401 || subsRes.status === 403) {
return { plan: "Command Code", message: "Command Code authentication failed. Check the API key." };
}
if (!creditsRes.ok) {
return { plan: "Command Code", message: `Command Code credits API error (${creditsRes.status})` };
}
if (!subsRes.ok) {
return { plan: "Command Code", message: `Command Code subscriptions API error (${subsRes.status})` };
}
const credits = await creditsRes.json().catch(() => null);
const subs = await subsRes.json().catch(() => null);
const creditsBody = await creditsRes.json().catch(() => ({}));
const subsBody = await subsRes.json().catch(() => ({}));
const planId = subsBody?.data?.planId ?? null;
const plan = (planId && PLAN_NAMES[planId]) || planId || "Command Code";
const cap = planId ? (PLAN_CAPS[planId] || 0) : 0;
const c = creditsBody?.credits || {};
const remaining =
toFiniteNumber(c.monthlyCredits, 0) +
toFiniteNumber(c.purchasedCredits, 0) +
toFiniteNumber(c.freeCredits, 0);
const used = cap > 0 ? Math.max(0, cap - remaining) : 0;
const total = cap > 0 ? cap : remaining;
const subData = subs?.data;
const planId = subData?.planId ?? null;
const periodStart = subData?.currentPeriodStart ?? null;
const quotas = {};
quotas.Credits = {
used,
total,
remaining,
unlimited: cap <= 0,
resetAt: parseResetTime(subsBody?.data?.currentPeriodEnd),
};
// Summary needs `since`; the CLI falls back to first-of-month when the
// subscription period start is unavailable.
const since = periodStart || firstOfMonth();
const summaryRes = await proxyAwareFetch(
`${SUMMARY_URL}?since=${encodeURIComponent(since)}`,
{ method: "GET", headers },
proxyOptions,
);
const summary = summaryRes.ok
? await summaryRes.json().catch(() => null)
: null;
const fiveHour = windowQuota(creditsBody?.windowLimits?.fiveHour);
if (fiveHour) quotas["Session (5h)"] = fiveHour;
const weekly = windowQuota(creditsBody?.windowLimits?.weekly);
if (weekly) quotas.Weekly = weekly;
const quotas = {};
const windowLimits = credits?.windowLimits || {};
const fiveHour = windowLimits.fiveHour;
if (fiveHour && Number(fiveHour.cap) > 0) {
quotas["5-hour window"] = makeQuota({
used: fiveHour.used,
total: fiveHour.cap,
resetAt: parseResetTime(fiveHour.resetAt),
});
}
const weekly = windowLimits.weekly;
if (weekly && Number(weekly.cap) > 0) {
quotas["Weekly window"] = makeQuota({
used: weekly.used,
total: weekly.cap,
resetAt: parseResetTime(weekly.resetAt),
});
}
// The credits API reports remaining balances (monthly/purchased/free),
// not a total. The official CLI renders the monthly line as
// `used = summary.totalCost`, `total = totalCost + remaining` — i.e.
// the plan ceiling is the sum of what was consumed and what is left.
const monthlyUsed =
typeof summary?.totalCredits === "number"
? summary.totalCredits
: typeof summary?.totalCost === "number"
? summary.totalCost
: 0;
const creditsObj = credits?.credits || {};
const remaining =
Math.max(0, Number(creditsObj.monthlyCredits) || 0) +
Math.max(0, Number(creditsObj.purchasedCredits) || 0) +
Math.max(0, Number(creditsObj.freeCredits) || 0);
const monthlyTotal = monthlyUsed + remaining;
if (monthlyTotal > 0 || monthlyUsed > 0) {
quotas["Monthly credits"] = makeQuota({
used: monthlyUsed,
total: monthlyTotal,
resetAt: periodStart ? undefined : null,
});
}
if (Object.keys(quotas).length === 0) {
return {
plan: planId || "CommandCode",
message: "CommandCode connected, but no quota was reported.",
quotas: {},
};
}
return {
plan: planId || "CommandCode",
quotas,
periodBasis: summary?.periodBasis || "billing-period",
};
} catch (error) {
return { message: `CommandCode usage error: ${error.message}` };
}
}
function firstOfMonth() {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
return { plan, quotas };
} catch (error) {
return { message: `Command Code error: ${error.message}` };
}
}

View File

@@ -91,14 +91,15 @@ export async function getDeepseekUsage(apiKey = null, proxyOptions = null) {
const quotas = {};
for (const b of balances) {
const total = Math.max(0, b.totalBalance);
// Credit pot: show full remaining against current balance; never set absolute
// `remaining` — QuotaTable treats it as a 0–100 percentage.
// Credit balance: show as "Credit: $X.XX USD" not a usage quota
quotas[`Balance (${b.currency})`] = {
used: 0,
total,
remainingPercentage: total > 0 ? 100 : 0,
resetAt: null,
unlimited: total > 0,
unlimited: false,
isCreditBalance: true,
currency: b.currency,
};
}