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

Resolve conflicts:
- streamingHandler.js: adopt upstreamResponseHeaders while keeping 0-token detail row avoidance
- capabilities.js: preserve user-asserted caps and globalThis slots without local caching of catalogSource
- AddCustomModelModal.js & providers/[id]/page.js: wire STT transport marker with custom model edits/assertions
- models/custom/route.js & aliasRepo.js: persist custom model transport and invalidate user caps
- usageRepo.js: key byApiKey live stats by full API key and keep tail in maskApiKey
- UsageStats.js: lazy load charts dynamically
This commit is contained in:
2026-09-28 21:17:33 +07:00
123 changed files with 5882 additions and 411 deletions

View File

@@ -25,10 +25,25 @@ function deriveUuid(seed) {
function generateFakeUserID(sessionId, apiKey) {
const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex");
const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID();
const sessionUuid = sessionId || randomUUID();
const cleanSessionId = typeof sessionId === "string" ? sessionId.replace(/^claude:/i, "").trim() : null;
const sessionUuid = cleanSessionId || randomUUID();
return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`;
}
export function extractClaudeSessionIdFromUserId(userId) {
if (typeof userId !== "string" || !userId) return null;
if (userId[0] === "{") {
try {
const sid = JSON.parse(userId)?.session_id;
return typeof sid === "string" && sid ? sid.replace(/^claude:/i, "").trim() || null : null;
} catch {
return null;
}
}
const clean = userId.replace(/^claude:/i, "").trim();
return clean || null;
}
/**
* Cloak tools before sending to Claude provider (anti-ban):
* - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[]
@@ -89,14 +104,29 @@ export function cloakClaudeTools(body) {
};
}
// Strip a trailing CLAUDE_TOOL_SUFFIX from a cloaked name as a last-resort
// fallback when the name isn't in toolNameMap (e.g. map lost across a retry/
// reconnect). Never strips decoy names — those are meant to reach the client
// unresolved so it can see "tool unavailable" instead of silently no-oping.
function stripCloakSuffix(name) {
if (typeof name !== "string" || !name.endsWith(CLAUDE_TOOL_SUFFIX)) return null;
if (CC_DEFAULT_TOOLS.has(name)) return null;
const original = name.slice(0, -CLAUDE_TOOL_SUFFIX.length);
return original.length > 0 ? original : null;
}
// Decloak tool_use names in non-streaming Claude response body (INPUT side)
export function decloakToolNames(body, toolNameMap) {
if (!toolNameMap?.size || !Array.isArray(body?.content)) return body;
if (!Array.isArray(body?.content)) return body;
const content = body.content.map(block => {
if (block?.type === "tool_use" && toolNameMap.has(block.name)) {
if (block?.type !== "tool_use") return block;
if (toolNameMap?.has(block.name)) {
return { ...block, name: toolNameMap.get(block.name) };
}
return block;
// toolNameMap missing/stale for this name — fall back to suffix stripping
// rather than forwarding an unresolvable "<tool>_ide" name to the client.
const fallback = stripCloakSuffix(block.name);
return fallback ? { ...block, name: fallback } : block;
});
return { ...body, content };
}
@@ -111,19 +141,21 @@ export function decloakToolNames(body, toolNameMap) {
* name appears exactly once per call — on the content_block_start event of
* a tool_use block; argument deltas carry no name.
*
* Unknown names (e.g. a CC decoy tool the model called anyway) pass through
* unchanged, matching the non-streaming decloak behavior.
* Falls back to stripping the literal CLAUDE_TOOL_SUFFIX when the name isn't
* in toolNameMap (map lost across a retry/reconnect), matching the
* non-streaming decloak behavior. Decoy tool names (real CC tool names) and
* anything else pass through unchanged.
*
* @param {object|null} chunk - Parsed SSE event (may be null on stream flush)
* @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools()
* @returns {object|null} The chunk, with the tool_use name restored when cloaked
*/
export function decloakStreamChunk(chunk, toolNameMap) {
if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk;
if (!chunk || typeof chunk !== "object") return chunk;
if (chunk.type !== "content_block_start") return chunk;
const block = chunk.content_block;
if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk;
const original = toolNameMap.get(block.name);
const original = toolNameMap?.get(block.name) || stripCloakSuffix(block.name);
if (!original) return chunk;
return { ...chunk, content_block: { ...block, name: original } };
}

View File

@@ -27,12 +27,13 @@ export function buildErrorBody(statusCode, message) {
* @param {string} message - Error message
* @returns {Response} HTTP Response object
*/
export function errorResponse(statusCode, message) {
export function errorResponse(statusCode, message, extraHeaders = null) {
return new Response(JSON.stringify(buildErrorBody(statusCode, message)), {
status: statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
"Access-Control-Allow-Origin": "*",
...extraHeaders
}
});
}
@@ -95,13 +96,13 @@ export async function parseUpstreamError(response, executor = null) {
* @param {number} [resetsAtMs] - Optional precise cooldown expiry (ms epoch) for provider-specific quota errors
* @returns {{ success: false, status: number, error: string, response: Response, resetsAtMs?: number }}
*/
export function createErrorResult(statusCode, message, resetsAtMs) {
export function createErrorResult(statusCode, message, resetsAtMs, extraHeaders = null) {
return {
success: false,
status: statusCode,
error: message,
resetsAtMs,
response: errorResponse(statusCode, message)
response: errorResponse(statusCode, message, extraHeaders)
};
}
@@ -113,7 +114,7 @@ export function createErrorResult(statusCode, message, resetsAtMs) {
* @param {string} retryAfterHuman - Human-readable retry info e.g. "reset after 30s"
* @returns {Response}
*/
export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman) {
export function unavailableResponse(statusCode, message, retryAfter, retryAfterHuman, extraHeaders = null) {
const retryAfterSec = Math.max(Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000), 1);
const msg = `${message} (${retryAfterHuman})`;
return new Response(
@@ -121,8 +122,10 @@ export function unavailableResponse(statusCode, message, retryAfter, retryAfterH
{
status: statusCode,
headers: {
...extraHeaders,
"Content-Type": "application/json",
"Retry-After": String(retryAfterSec)
// Intentionally mis-cased to prevent duplicate headers
"retry-after": String(retryAfterSec)
}
}
);

View File

@@ -0,0 +1,12 @@
const FORWARDED = new Set(["retry-after", "x-should-retry"]);
const FORWARDED_PREFIX = "anthropic-ratelimit-";
export function upstreamResponseHeaders(headers) {
const out = {};
if (typeof headers?.forEach !== "function") return out;
headers.forEach((value, name) => {
const key = name.toLowerCase();
if (FORWARDED.has(key) || key.startsWith(FORWARDED_PREFIX)) out[key] = value;
});
return out;
}