diff --git a/next.config.mjs b/next.config.mjs index 6c2a060519..4e66ad3db3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -52,6 +52,7 @@ const nextConfig = { "stream", "buffer", "util", + "process", ], transpilePackages: ["@omniroute/open-sse"], allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"], @@ -110,6 +111,7 @@ const nextConfig = { "stream", "buffer", "util", + "process", ]); const prev = config.externals ?? []; @@ -144,6 +146,7 @@ const nextConfig = { net: false, tls: false, crypto: false, + process: false, }; } return config; diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index ca428764f6..25fef8f32a 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -96,39 +96,20 @@ export const HTTP_STATUS = { SERVICE_UNAVAILABLE: 503, GATEWAY_TIMEOUT: 504, }; - -// OpenAI-compatible error types mapping -export const ERROR_TYPES = { - [HTTP_STATUS.BAD_REQUEST]: { type: "invalid_request_error", code: "bad_request" }, - [HTTP_STATUS.UNAUTHORIZED]: { type: "authentication_error", code: "invalid_api_key" }, - [HTTP_STATUS.FORBIDDEN]: { type: "permission_error", code: "insufficient_quota" }, - [HTTP_STATUS.NOT_FOUND]: { type: "invalid_request_error", code: "model_not_found" }, - [HTTP_STATUS.RATE_LIMITED]: { type: "rate_limit_error", code: "rate_limit_exceeded" }, - [HTTP_STATUS.SERVER_ERROR]: { type: "server_error", code: "internal_server_error" }, - [HTTP_STATUS.BAD_GATEWAY]: { type: "server_error", code: "bad_gateway" }, - [HTTP_STATUS.SERVICE_UNAVAILABLE]: { type: "server_error", code: "service_unavailable" }, - [HTTP_STATUS.GATEWAY_TIMEOUT]: { type: "server_error", code: "gateway_timeout" }, -}; - -// Default error messages per status code -export const DEFAULT_ERROR_MESSAGES = { - [HTTP_STATUS.BAD_REQUEST]: "Bad request", - [HTTP_STATUS.UNAUTHORIZED]: "Invalid API key provided", - [HTTP_STATUS.FORBIDDEN]: "You exceeded your current quota", - [HTTP_STATUS.NOT_FOUND]: "Model not found", - [HTTP_STATUS.RATE_LIMITED]: "Rate limit exceeded", - [HTTP_STATUS.SERVER_ERROR]: "Internal server error", - [HTTP_STATUS.BAD_GATEWAY]: "Bad gateway - upstream provider error", - [HTTP_STATUS.SERVICE_UNAVAILABLE]: "Service temporarily unavailable", - [HTTP_STATUS.GATEWAY_TIMEOUT]: "Gateway timeout", -}; - -// Exponential backoff config for rate limits (like CLIProxyAPI) -export const BACKOFF_CONFIG = { - base: 1000, // 1 second base - max: 2 * 60 * 1000, // 2 minutes max - maxLevel: 15, // Cap backoff level -}; +export { + BACKOFF_CONFIG, + COOLDOWN_MS, + DEFAULT_ERROR_MESSAGES, + ERROR_RULES, + ERROR_TYPES, + TRANSIENT_COOLDOWN_MS, + calculateBackoffCooldown, + findMatchingErrorRule, + getDefaultErrorMessage, + getErrorInfo, + matchErrorRuleByStatus, + matchErrorRuleByText, +} from "./errorConfig.ts"; // Configurable backoff steps for rate limits (Phase 1 — enhanced rate limiting) // Used for per-model lockouts with increasing severity @@ -145,22 +126,6 @@ export const RateLimitReason = { UNKNOWN: "unknown", }; -// Error-based cooldown times (aligned with CLIProxyAPI) -export const COOLDOWN_MS = { - unauthorized: 2 * 60 * 1000, // 401 → 2 min - paymentRequired: 2 * 60 * 1000, // 402/403 → 2 min - notFound: 2 * 60 * 1000, // 404 → 2 minutes - notFoundLocal: 5 * 1000, // 404 on local provider → 5s model-only lockout (connection stays active) - transientInitial: 5 * 1000, // 408/500/502/503/504 first hit → 5s (backoff from here) - transientMax: 60 * 1000, // 502/503/504 backoff ceiling → 60s - transient: 5 * 1000, // Legacy alias → points to transientInitial - requestNotAllowed: 5 * 1000, // "Request not allowed" → 5 sec - // Legacy aliases for backward compatibility - rateLimit: 2 * 60 * 1000, - serviceUnavailable: 2 * 1000, - authExpired: 2 * 60 * 1000, -}; - // ─── Provider Resilience Profiles ─────────────────────────────────────────── // Separate behavior for OAuth (low-limit, session-based) vs API Key (high-limit, metered) export const PROVIDER_PROFILES = { diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts new file mode 100644 index 0000000000..84c6df8bd4 --- /dev/null +++ b/open-sse/config/errorConfig.ts @@ -0,0 +1,182 @@ +export type ErrorInfo = { + type: string; + code: string; +}; + +export type ConfiguredErrorReason = + | "auth_error" + | "quota_exhausted" + | "rate_limit_exceeded" + | "model_capacity" + | "server_error" + | "unknown"; + +export type ErrorRule = { + id: string; + text?: string; + status?: number; + reason?: ConfiguredErrorReason; + cooldownMs?: number; + backoff?: boolean; +}; + +// OpenAI-compatible error types mapping (client-facing) +export const ERROR_TYPES: Record = { + 400: { type: "invalid_request_error", code: "bad_request" }, + 401: { type: "authentication_error", code: "invalid_api_key" }, + 402: { type: "billing_error", code: "payment_required" }, + 403: { type: "permission_error", code: "insufficient_quota" }, + 404: { type: "invalid_request_error", code: "model_not_found" }, + 406: { type: "invalid_request_error", code: "model_not_supported" }, + 429: { type: "rate_limit_error", code: "rate_limit_exceeded" }, + 500: { type: "server_error", code: "internal_server_error" }, + 502: { type: "server_error", code: "bad_gateway" }, + 503: { type: "server_error", code: "service_unavailable" }, + 504: { type: "server_error", code: "gateway_timeout" }, +}; + +// Default error messages per status code (client-facing) +export const DEFAULT_ERROR_MESSAGES: Record = { + 400: "Bad request", + 401: "Invalid API key provided", + 402: "Payment required", + 403: "You exceeded your current quota", + 404: "Model not found", + 406: "Model not supported", + 429: "Rate limit exceeded", + 500: "Internal server error", + 502: "Bad gateway - upstream provider error", + 503: "Service temporarily unavailable", + 504: "Gateway timeout", +}; + +// Exponential backoff config for rate limits. +// Preserve OmniRoute's existing 2-minute cap to avoid changing runtime behavior. +export const BACKOFF_CONFIG = { + base: 1000, + max: 2 * 60 * 1000, + maxLevel: 15, +}; + +export const TRANSIENT_COOLDOWN_MS = 5 * 1000; + +// Cooldown durations (ms) +export const COOLDOWN_MS = { + unauthorized: 2 * 60 * 1000, + paymentRequired: 2 * 60 * 1000, + notFound: 2 * 60 * 1000, + notFoundLocal: 5 * 1000, + transientInitial: TRANSIENT_COOLDOWN_MS, + transientMax: 60 * 1000, + transient: TRANSIENT_COOLDOWN_MS, + requestNotAllowed: 5 * 1000, + rateLimit: 2 * 60 * 1000, + serviceUnavailable: 2 * 1000, + authExpired: 2 * 60 * 1000, +}; + +/** + * Shared rules for account fallback classification. + * Checked top-to-bottom: text rules first, then status rules. + */ +export const ERROR_RULES: ErrorRule[] = [ + { + id: "no_credentials", + text: "no credentials", + cooldownMs: COOLDOWN_MS.notFound, + reason: "auth_error", + }, + { + id: "request_not_allowed", + text: "request not allowed", + cooldownMs: COOLDOWN_MS.requestNotAllowed, + reason: "rate_limit_exceeded", + }, + { + id: "improperly_formed_request", + text: "improperly formed request", + cooldownMs: 0, + reason: "model_capacity", + }, + { id: "rate_limit", text: "rate limit", backoff: true, reason: "rate_limit_exceeded" }, + { + id: "too_many_requests", + text: "too many requests", + backoff: true, + reason: "rate_limit_exceeded", + }, + { + id: "quota_exceeded", + text: "quota exceeded", + backoff: true, + reason: "quota_exhausted", + }, + { + id: "quota_will_reset", + text: "quota will reset", + backoff: true, + reason: "quota_exhausted", + }, + { + id: "capacity_exhausted", + text: "exhausted your capacity", + backoff: true, + reason: "quota_exhausted", + }, + { + id: "quota_exhausted", + text: "quota exhausted", + backoff: true, + reason: "quota_exhausted", + }, + { id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" }, + { id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" }, + { id: "status_401", status: 401, cooldownMs: 0, reason: "auth_error" }, + { id: "status_402", status: 402, cooldownMs: 0, reason: "quota_exhausted" }, + { id: "status_403", status: 403, cooldownMs: 0, reason: "quota_exhausted" }, + { id: "status_404", status: 404, cooldownMs: COOLDOWN_MS.notFound, reason: "unknown" }, + { id: "status_406", status: 406, backoff: true, reason: "server_error" }, + { id: "status_408", status: 408, backoff: true, reason: "server_error" }, + { id: "status_429", status: 429, backoff: true, reason: "rate_limit_exceeded" }, + { id: "status_500", status: 500, backoff: true, reason: "server_error" }, + { id: "status_502", status: 502, backoff: true, reason: "server_error" }, + { id: "status_503", status: 503, backoff: true, reason: "server_error" }, + { id: "status_504", status: 504, backoff: true, reason: "server_error" }, +]; + +function normalizeErrorMessage(message: unknown): string { + return String(message || "").toLowerCase(); +} + +export function getErrorInfo(statusCode: number): ErrorInfo { + return ( + ERROR_TYPES[statusCode] || + (statusCode >= 500 + ? { type: "server_error", code: "internal_server_error" } + : { type: "invalid_request_error", code: "" }) + ); +} + +export function getDefaultErrorMessage(statusCode: number): string { + return DEFAULT_ERROR_MESSAGES[statusCode] || "An error occurred"; +} + +export function calculateBackoffCooldown(level = 0): number { + const safeLevel = Math.max(0, Math.floor(level)); + const cooldown = BACKOFF_CONFIG.base * Math.pow(2, safeLevel); + return Math.min(cooldown, BACKOFF_CONFIG.max); +} + +export function matchErrorRuleByText(message: unknown): ErrorRule | null { + const lower = normalizeErrorMessage(message); + if (!lower) return null; + return ERROR_RULES.find((rule) => rule.text && lower.includes(rule.text)) || null; +} + +export function matchErrorRuleByStatus(statusCode: number): ErrorRule | null { + return ERROR_RULES.find((rule) => rule.status === statusCode) || null; +} + +export function findMatchingErrorRule(statusCode: number, message: unknown): ErrorRule | null { + return matchErrorRuleByText(message) || matchErrorRuleByStatus(statusCode); +} diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 805c3f1324..73f0311f39 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -1046,6 +1046,53 @@ export const REGISTRY: Record = { ], }, + "blackbox-web": { + id: "blackbox-web", + alias: "bb-web", + format: "openai", + executor: "blackbox-web", + baseUrl: "https://app.blackbox.ai/api/chat", + authType: "apikey", + authHeader: "cookie", + models: [ + { id: "minimax/minimax-m2.7", name: "MiniMax M2.7" }, + { id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6" }, + { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, + { id: "openai/gpt-5.4", name: "GPT-5.4" }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, + { id: "z-ai/glm-5", name: "GLM-5" }, + { id: "x-ai/grok-4.1-fast", name: "Grok 4.1 Fast" }, + { id: "blackbox/encrypted", name: "Blackbox Encrypted" }, + ], + }, + + "muse-spark-web": { + id: "muse-spark-web", + alias: "ms-web", + format: "openai", + executor: "muse-spark-web", + baseUrl: "https://www.meta.ai/api/graphql", + authType: "apikey", + authHeader: "cookie", + defaultContextLength: 262000, + models: [ + { id: "muse-spark", name: "Muse Spark", contextLength: 262000 }, + { + id: "muse-spark-thinking", + name: "Muse Spark Thinking", + supportsReasoning: true, + contextLength: 262000, + }, + { + id: "muse-spark-contemplating", + name: "Muse Spark Contemplating", + supportsReasoning: true, + contextLength: 262000, + }, + ], + }, + xai: { id: "xai", alias: "xai", diff --git a/open-sse/config/toolCloaking.ts b/open-sse/config/toolCloaking.ts new file mode 100644 index 0000000000..7fa5cd3b35 --- /dev/null +++ b/open-sse/config/toolCloaking.ts @@ -0,0 +1,208 @@ +type JsonRecord = Record; + +export const AG_TOOL_SUFFIX = "_ide"; + +const AG_DEFAULT_TOOL_NAMES = [ + "browser_subagent", + "command_status", + "find_by_name", + "generate_image", + "grep_search", + "list_dir", + "list_resources", + "multi_replace_file_content", + "notify_user", + "read_resource", + "read_terminal", + "read_url_content", + "replace_file_content", + "run_command", + "search_web", + "send_command_input", + "task_boundary", + "view_content_chunk", + "view_file", + "write_to_file", +] as const; + +const AG_DECOY_TOOL_NAMES = [ + ...AG_DEFAULT_TOOL_NAMES, + "mcp_sequential-thinking_sequentialthinking", +] as const; + +export const AG_DEFAULT_TOOLS = new Set(AG_DEFAULT_TOOL_NAMES); + +export const AG_DECOY_TOOLS = AG_DECOY_TOOL_NAMES.map((name) => + Object.freeze({ + name, + description: "This tool is currently unavailable.", + parameters: { + type: "OBJECT", + properties: {}, + required: [], + }, + }) +); + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function toToolName(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function shouldCloakAntigravityTool(toolName: string): boolean { + return ( + toolName.length > 0 && !AG_DEFAULT_TOOLS.has(toolName) && !toolName.endsWith(AG_TOOL_SUFFIX) + ); +} + +export function getCloakedAntigravityToolName(toolName: string): string { + return shouldCloakAntigravityTool(toolName) ? `${toolName}${AG_TOOL_SUFFIX}` : toolName; +} + +export function cloakAntigravityToolPayload( + body: T +): { + body: T; + toolNameMap: Map | null; +} { + const request = asRecord(body.request); + if (!request) { + return { body, toolNameMap: null }; + } + + const existingToolNameMap = + body._toolNameMap instanceof Map ? (body._toolNameMap as Map) : null; + const toolNameMap = existingToolNameMap + ? new Map(existingToolNameMap) + : new Map(); + let changed = false; + + const nextRequest: JsonRecord = { + ...request, + }; + + if (Array.isArray(request.tools)) { + const preservedTools: JsonRecord[] = []; + const cloakedDeclarations: JsonRecord[] = []; + + for (const toolValue of request.tools) { + const tool = asRecord(toolValue); + if (!tool || !Array.isArray(tool.functionDeclarations)) { + preservedTools.push(toolValue as JsonRecord); + continue; + } + + for (const declarationValue of tool.functionDeclarations) { + const declaration = asRecord(declarationValue); + if (!declaration) continue; + + const rawName = toToolName(declaration.name); + if (!rawName) { + cloakedDeclarations.push({ ...declaration }); + continue; + } + + const cloakedName = getCloakedAntigravityToolName(rawName); + if (cloakedName !== rawName) { + changed = true; + toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); + } + + cloakedDeclarations.push({ + ...declaration, + name: cloakedName, + }); + } + } + + if (cloakedDeclarations.length > 0) { + const declaredNames = new Set( + cloakedDeclarations + .map((declaration) => toToolName(declaration.name)) + .filter((name) => name.length > 0) + ); + const decoys = AG_DECOY_TOOLS.filter((declaration) => !declaredNames.has(declaration.name)); + nextRequest.tools = [ + ...preservedTools, + { functionDeclarations: [...cloakedDeclarations, ...decoys] }, + ]; + changed = true; + } + } + + if (Array.isArray(request.contents)) { + let contentsChanged = false; + const nextContents = request.contents.map((contentValue) => { + const content = asRecord(contentValue); + if (!content || !Array.isArray(content.parts)) return contentValue; + + let partChanged = false; + const nextParts = content.parts.map((partValue) => { + const part = asRecord(partValue); + if (!part) return partValue; + + const nextPart: JsonRecord = { ...part }; + + const functionCall = asRecord(part.functionCall); + if (functionCall) { + const rawName = toToolName(functionCall.name); + const cloakedName = getCloakedAntigravityToolName(rawName); + if (cloakedName !== rawName) { + nextPart.functionCall = { + ...functionCall, + name: cloakedName, + }; + toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); + partChanged = true; + } + } + + const functionResponse = asRecord(part.functionResponse); + if (functionResponse) { + const rawName = toToolName(functionResponse.name); + const cloakedName = getCloakedAntigravityToolName(rawName); + if (cloakedName !== rawName) { + nextPart.functionResponse = { + ...functionResponse, + name: cloakedName, + }; + toolNameMap.set(cloakedName, toolNameMap.get(rawName) ?? rawName); + partChanged = true; + } + } + + return partChanged ? nextPart : partValue; + }); + + if (!partChanged) return contentValue; + contentsChanged = true; + return { + ...content, + parts: nextParts, + }; + }); + + if (contentsChanged) { + nextRequest.contents = nextContents; + changed = true; + } + } + + if (!changed) { + return { + body, + toolNameMap: toolNameMap.size > 0 ? toolNameMap : null, + }; + } + + return { + body: { + ...body, + request: nextRequest, + }, + toolNameMap: toolNameMap.size > 0 ? toolNameMap : null, + }; +} diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index b987232735..5b9a3ed0d9 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -15,6 +15,7 @@ import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/cr import { obfuscateSensitiveWords } from "../services/antigravityObfuscation.ts"; import { resolveAntigravityVersion } from "../services/antigravityVersion.ts"; import { resolveAntigravityModelId } from "../config/antigravityModelAliases.ts"; +import { cloakAntigravityToolPayload } from "../config/toolCloaking.ts"; import { shouldStripCloudCodeThinking, stripCloudCodeThinkingConfig, @@ -106,6 +107,21 @@ function cleanModelName(model: string): string { return clean; } +function attachToolNameMap(payload: T, toolNameMap: Map | null): T { + if (!toolNameMap?.size || !payload || typeof payload !== "object") { + return payload; + } + + const copy = Array.isArray(payload) ? ([...payload] as T) : ({ ...(payload as object) } as T); + Object.defineProperty(copy, "_toolNameMap", { + value: toolNameMap, + enumerable: false, + configurable: true, + writable: true, + }); + return copy; +} + export class AntigravityExecutor extends BaseExecutor { constructor() { super("antigravity", PROVIDERS.antigravity); @@ -490,6 +506,17 @@ export class AntigravityExecutor extends BaseExecutor { const headers = this.buildHeaders(credentials, upstreamStream); mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); let transformedBody = await this.transformRequest(model, body, upstreamStream, credentials); + let requestToolNameMap: Map | null = null; + + if (transformedBody instanceof Response) { + return { response: transformedBody, url, headers, transformedBody: body }; + } + + if (transformedBody && typeof transformedBody === "object") { + const cloaked = cloakAntigravityToolPayload(transformedBody as Record); + transformedBody = cloaked.body; + requestToolNameMap = cloaked.toolNameMap; + } // Credits-first: inject GOOGLE_ONE_AI upfront so we never try the normal // quota path. If credits are exhausted / disabled shouldUseCreditsFirst() @@ -592,9 +619,17 @@ export class AntigravityExecutor extends BaseExecutor { } catch { /**/ } - return collected; + return { + ...collected, + transformedBody: attachToolNameMap(creditsBody, requestToolNameMap), + }; } - return { response: creditsResp, url, headers, transformedBody: creditsBody }; + return { + response: creditsResp, + url, + headers, + transformedBody: attachToolNameMap(creditsBody, requestToolNameMap), + }; } // Credit retry also 429'd @@ -690,7 +725,12 @@ export class AntigravityExecutor extends BaseExecutor { status: response.status, headers: response.headers, }); - return { response: modifiedResponse, url, headers, transformedBody }; + return { + response: modifiedResponse, + url, + headers, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; } catch (err) { log?.warn?.("RETRY", `Failed to embed retryAfterMs: ${err}`); // Fall back to original response @@ -727,7 +767,10 @@ export class AntigravityExecutor extends BaseExecutor { } catch { /* balance cache is best-effort */ } - return collected; + return { + ...collected, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; } // Streaming path: wrap the response body in a pass-through TransformStream @@ -811,10 +854,20 @@ export class AntigravityExecutor extends BaseExecutor { statusText: response.statusText, headers: response.headers, }); - return { response: tappedResponse, url, headers, transformedBody }; + return { + response: tappedResponse, + url, + headers, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; } - return { response, url, headers, transformedBody }; + return { + response, + url, + headers, + transformedBody: attachToolNameMap(transformedBody, requestToolNameMap), + }; } catch (error) { lastError = error; if (urlIndex + 1 < fallbackCount) { diff --git a/open-sse/executors/blackbox-web.ts b/open-sse/executors/blackbox-web.ts new file mode 100644 index 0000000000..b0b2d29c87 --- /dev/null +++ b/open-sse/executors/blackbox-web.ts @@ -0,0 +1,458 @@ +import { + BaseExecutor, + mergeAbortSignals, + mergeUpstreamExtraHeaders, + type ExecuteInput, +} from "./base.ts"; +import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; + +const BLACKBOX_CHAT_API = "https://app.blackbox.ai/api/chat"; +const BLACKBOX_DEFAULT_COOKIE = "__Secure-authjs.session-token"; +const BLACKBOX_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; + +type BlackboxMessage = { + id: string; + role: "user" | "assistant"; + content: string; +}; + +function extractMessageText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + + if (!Array.isArray(content)) { + return ""; + } + + return content + .map((part) => { + if (!part || typeof part !== "object") return ""; + const item = part as Record; + if (item.type === "text" && typeof item.text === "string") { + return item.text; + } + if (item.type === "input_text" && typeof item.text === "string") { + return item.text; + } + return ""; + }) + .filter((part) => part.trim().length > 0) + .join("\n") + .trim(); +} + +function parseOpenAIMessages( + messages: Array>, + chatId: string +): BlackboxMessage[] { + const systemParts: string[] = []; + const parsed: BlackboxMessage[] = []; + + for (const message of messages) { + const role = String(message.role || "user"); + const content = extractMessageText(message.content); + if (!content) continue; + + if (role === "system" || role === "developer") { + systemParts.push(content); + continue; + } + + if (role === "assistant" || role === "user") { + parsed.push({ + id: role === "user" ? chatId : crypto.randomUUID(), + role, + content, + }); + } + } + + if (systemParts.length > 0) { + const prefix = `System instructions:\n${systemParts.join("\n\n")}`; + const firstUserIndex = parsed.findIndex((message) => message.role === "user"); + if (firstUserIndex >= 0) { + parsed[firstUserIndex] = { + ...parsed[firstUserIndex], + content: `${prefix}\n\n${parsed[firstUserIndex].content}`, + }; + } else { + parsed.unshift({ + id: chatId, + role: "user", + content: prefix, + }); + } + } + + return parsed; +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil((text || "").length / 4)); +} + +function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +async function readTextResponse( + body: ReadableStream, + signal?: AbortSignal | null +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + try { + while (true) { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } + + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +function buildStreamingResponse( + responseText: string, + model: string, + id: string, + created: number +): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { role: "assistant" }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + + if (responseText) { + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { content: responseText }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + } + + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); +} + +function buildNonStreamingResponse( + responseText: string, + model: string, + id: string, + created: number +) { + const completionTokens = estimateTokens(responseText); + + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + message: { role: "assistant", content: responseText }, + finish_reason: "stop", + logprobs: null, + }, + ], + usage: { + prompt_tokens: completionTokens, + completion_tokens: completionTokens, + total_tokens: completionTokens * 2, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +export function normalizeBlackboxCookieHeader(apiKey: string): string { + return normalizeSessionCookieHeader(apiKey, BLACKBOX_DEFAULT_COOKIE); +} + +export class BlackboxWebExecutor extends BaseExecutor { + constructor() { + super("blackbox-web", { id: "blackbox-web", baseUrl: BLACKBOX_CHAT_API }); + } + + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { + const messages = (body as Record).messages as + | Array> + | undefined; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + const errorResponse = new Response( + JSON.stringify({ + error: { + message: "Missing or empty messages array", + type: "invalid_request", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + return { + response: errorResponse, + url: BLACKBOX_CHAT_API, + headers: {}, + transformedBody: body, + }; + } + + const chatId = crypto.randomUUID().slice(0, 7); + const parsedMessages = parseOpenAIMessages(messages, chatId); + if (parsedMessages.length === 0) { + const errorResponse = new Response( + JSON.stringify({ + error: { + message: "Empty query after processing messages", + type: "invalid_request", + }, + }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + return { + response: errorResponse, + url: BLACKBOX_CHAT_API, + headers: {}, + transformedBody: body, + }; + } + + const cookieHeader = normalizeBlackboxCookieHeader(credentials.apiKey || ""); + const headers: Record = { + Accept: "text/plain, */*", + "Content-Type": "application/json", + Cookie: cookieHeader, + Origin: "https://app.blackbox.ai", + Referer: `https://app.blackbox.ai/chat/${chatId}`, + "User-Agent": BLACKBOX_USER_AGENT, + }; + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); + + const transformedBody = { + messages: parsedMessages, + id: chatId, + previewToken: null, + userId: credentials.providerSpecificData?.userId ?? null, + codeModelMode: true, + trendingAgentMode: {}, + isMicMode: false, + userSystemPrompt: null, + maxTokens: Number((body as Record).max_tokens) || 1024, + playgroundTopP: null, + playgroundTemperature: null, + isChromeExt: false, + githubToken: "", + clickedAnswer2: false, + clickedAnswer3: false, + clickedForceWebSearch: false, + visitFromDelta: false, + isMemoryEnabled: false, + mobileClient: false, + userSelectedModel: model || null, + userSelectedAgent: "VscodeAgent", + validated: crypto.randomUUID(), + imageGenerationMode: false, + imageGenMode: "autoMode", + webSearchModePrompt: false, + deepSearchMode: false, + promptSelection: "", + domains: null, + vscodeClient: false, + codeInterpreterMode: false, + customProfile: { + name: "", + occupation: "", + traits: [], + additionalInfo: "", + enableNewChats: false, + }, + webSearchModeOption: { + autoMode: true, + webMode: false, + offlineMode: false, + }, + session: null, + isPremium: false, + teamAccount: "", + subscriptionCache: null, + beastMode: false, + reasoningMode: false, + designerMode: false, + workspaceId: "", + asyncMode: false, + integrations: {}, + isTaskPersistent: false, + selectedElement: null, + }; + + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(BLACKBOX_CHAT_API, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: combinedSignal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("BLACKBOX-WEB", `Fetch failed: ${message}`); + const errorResponse = new Response( + JSON.stringify({ + error: { + message: `Blackbox Web connection failed: ${message}`, + type: "upstream_error", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + return { + response: errorResponse, + url: BLACKBOX_CHAT_API, + headers, + transformedBody, + }; + } + + if (!upstreamResponse.ok) { + const status = upstreamResponse.status; + let message = `Blackbox Web returned HTTP ${status}`; + if (status === 401 || status === 403) { + message = + "Blackbox Web auth failed — your app.blackbox.ai session cookie may be missing or expired."; + } else if (status === 429) { + message = "Blackbox Web rate limited the session. Wait a moment and retry."; + } + const errorResponse = new Response( + JSON.stringify({ + error: { + message, + type: "upstream_error", + code: `HTTP_${status}`, + }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); + return { + response: errorResponse, + url: BLACKBOX_CHAT_API, + headers, + transformedBody, + }; + } + + if (!upstreamResponse.body) { + const errorResponse = new Response( + JSON.stringify({ + error: { + message: "Blackbox Web returned an empty response body", + type: "upstream_error", + }, + }), + { status: 502, headers: { "Content-Type": "application/json" } } + ); + return { + response: errorResponse, + url: BLACKBOX_CHAT_API, + headers, + transformedBody, + }; + } + + const responseText = (await readTextResponse(upstreamResponse.body, signal)).trim(); + const id = `chatcmpl-blackbox-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + const finalResponse = stream + ? new Response(buildStreamingResponse(responseText, model, id, created), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + }) + : buildNonStreamingResponse(responseText, model, id, created); + + return { + response: finalResponse, + url: BLACKBOX_CHAT_API, + headers, + transformedBody, + }; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 8d5a15fbe2..97010a66b4 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -14,6 +14,8 @@ import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; +import { BlackboxWebExecutor } from "./blackbox-web.ts"; +import { MuseSparkWebExecutor } from "./muse-spark-web.ts"; import { AzureOpenAIExecutor } from "./azure-openai.ts"; const executors = { @@ -41,6 +43,10 @@ const executors = { "perplexity-web": new PerplexityWebExecutor(), "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), + "blackbox-web": new BlackboxWebExecutor(), + "bb-web": new BlackboxWebExecutor(), // Alias + "muse-spark-web": new MuseSparkWebExecutor(), + "ms-web": new MuseSparkWebExecutor(), // Alias }; const defaultCache = new Map(); @@ -72,4 +78,6 @@ export { CliproxyapiExecutor } from "./cliproxyapi.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; +export { BlackboxWebExecutor } from "./blackbox-web.ts"; +export { MuseSparkWebExecutor } from "./muse-spark-web.ts"; export { AzureOpenAIExecutor } from "./azure-openai.ts"; diff --git a/open-sse/executors/muse-spark-web.ts b/open-sse/executors/muse-spark-web.ts new file mode 100644 index 0000000000..20d8e8598a --- /dev/null +++ b/open-sse/executors/muse-spark-web.ts @@ -0,0 +1,778 @@ +import { + BaseExecutor, + mergeAbortSignals, + mergeUpstreamExtraHeaders, + type ExecuteInput, +} from "./base.ts"; +import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; + +const META_AI_GRAPHQL_API = "https://www.meta.ai/api/graphql"; +const META_AI_DEFAULT_COOKIE = "abra_sess"; +const META_AI_SEND_MESSAGE_DOC_ID = "078dfdff6fb0d420d8011b49073e6886"; +const META_AI_ROOT_BRANCH_PATH = "0"; +const META_AI_ENTRY_POINT = "KADABRA__CHAT__UNIFIED_INPUT_BAR"; +const META_AI_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; +const BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +type MuseSparkModelInfo = { + mode: string; + isThinking: boolean; +}; + +const MODEL_MAP: Record = { + "muse-spark": { mode: "mode_fast", isThinking: false }, + "muse-spark-thinking": { mode: "mode_thinking", isThinking: true }, + "muse-spark-contemplating": { mode: "think_hard", isThinking: true }, +}; + +type MetaSseFrame = { + event: string; + data: string; +}; + +type ParsedMetaAiResponse = { + content: string; + deltas: string[]; + errorCode: string | null; + errorMessage: string | null; + status: number; +}; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function extractMessageText(content: unknown): string { + if (typeof content === "string") { + return content.trim(); + } + + if (!Array.isArray(content)) { + return ""; + } + + return content + .map((part) => { + if (!isRecord(part)) return ""; + if (part.type === "text" && typeof part.text === "string") { + return part.text; + } + if (part.type === "input_text" && typeof part.text === "string") { + return part.text; + } + return ""; + }) + .filter((part) => part.trim().length > 0) + .join("\n") + .trim(); +} + +function parseOpenAIMessages(messages: Array>): string { + const extracted: Array<{ role: string; content: string }> = []; + + for (const message of messages) { + let role = String(message.role || "user"); + if (role === "developer") role = "system"; + + const content = extractMessageText(message.content); + if (!content) continue; + extracted.push({ role, content }); + } + + if (extracted.length === 0) { + return ""; + } + + let lastUserIndex = -1; + for (let i = extracted.length - 1; i >= 0; i--) { + if (extracted[i].role === "user") { + lastUserIndex = i; + break; + } + } + + return extracted + .map((message, index) => { + if (index === lastUserIndex) { + return message.content; + } + return `${message.role}: ${message.content}`; + }) + .join("\n\n") + .trim(); +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil((text || "").length / 4)); +} + +function encodeBase62(value: bigint, padLength: number): string { + let remaining = value; + let encoded = ""; + + while (remaining > 0n) { + encoded = BASE62_ALPHABET[Number(remaining % 62n)] + encoded; + remaining /= 62n; + } + + return encoded.padStart(padLength, "0"); +} + +function decodeBase62(value: string): bigint { + let decoded = 0n; + for (const char of value) { + const index = BASE62_ALPHABET.indexOf(char); + if (index < 0) { + throw new Error(`Invalid base62 character: ${char}`); + } + decoded = decoded * 62n + BigInt(index); + } + return decoded; +} + +function randomBigInt(byteLength: number): bigint { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + let result = 0n; + for (const byte of bytes) { + result = (result << 8n) | BigInt(byte); + } + return result; +} + +function generateMetaConversationId(): string { + const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n); + const random = randomBigInt(8) & ((1n << 64n) - 1n); + const packed = (timestamp << 64n) | random; + return `c.${encodeBase62(packed, 19)}`; +} + +function generateMetaEventId(conversationId: string): string | null { + if (!conversationId.startsWith("c.")) { + return null; + } + + try { + const packedConversation = decodeBase62(conversationId.slice(2)); + const conversationRandom = packedConversation & ((1n << 64n) - 1n); + const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n); + const eventRandom = randomBigInt(4) & ((1n << 32n) - 1n); + const packedEvent = (timestamp << (64n + 32n)) | (conversationRandom << 32n) | eventRandom; + return `e.${encodeBase62(packedEvent, 25)}`; + } catch { + return null; + } +} + +function generateNumericMessageId(): string { + return ( + BigInt(Date.now()) * 1000n + + BigInt(Math.floor(Math.random() * 1000)) + + (randomBigInt(2) & 0xfffn) + ).toString(); +} + +function normalizeMetaLocale(): string { + const locale = + typeof Intl !== "undefined" + ? Intl.DateTimeFormat().resolvedOptions().locale || "en-US" + : "en-US"; + return locale.replace(/-/g, "_"); +} + +function getMuseSparkModelInfo(model: string): MuseSparkModelInfo { + return MODEL_MAP[model] || MODEL_MAP["muse-spark"]; +} + +function buildMetaAiRequestBody(prompt: string, model: string) { + const conversationId = generateMetaConversationId(); + const userUniqueMessageId = generateNumericMessageId(); + + return { + doc_id: META_AI_SEND_MESSAGE_DOC_ID, + variables: { + assistantMessageId: crypto.randomUUID(), + attachments: null, + clientLatitude: null, + clientLongitude: null, + clientTimezone: + typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC", + clippyIp: null, + content: prompt, + conversationId, + conversationStarterId: null, + currentBranchPath: META_AI_ROOT_BRANCH_PATH, + developerOverridesForMessage: null, + devicePixelRatio: 1, + entryPoint: META_AI_ENTRY_POINT, + imagineOperationRequest: null, + isNewConversation: true, + mentions: null, + mode: getMuseSparkModelInfo(model).mode, + promptEditType: null, + promptSessionId: crypto.randomUUID(), + promptType: null, + qplJoinId: null, + requestedToolCall: null, + rewriteOptions: null, + turnId: crypto.randomUUID(), + userAgent: META_AI_USER_AGENT, + userEventId: generateMetaEventId(conversationId), + userLocale: normalizeMetaLocale(), + userMessageId: crypto.randomUUID(), + userUniqueMessageId, + }, + }; +} + +function parseMetaSseFrames(text: string): MetaSseFrame[] { + const frames: MetaSseFrame[] = []; + const lines = text.split(/\r?\n/); + let currentEvent = "message"; + let dataLines: string[] = []; + + const flush = () => { + if (dataLines.length === 0 && currentEvent === "message") { + return; + } + + frames.push({ + event: currentEvent, + data: dataLines.join("\n").trim(), + }); + + currentEvent = "message"; + dataLines = []; + }; + + for (const line of lines) { + if (!line) { + flush(); + continue; + } + + if (line.startsWith(":")) { + continue; + } + + if (line.startsWith("event:")) { + currentEvent = line.slice("event:".length).trim() || "message"; + continue; + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + + flush(); + return frames; +} + +function readMetaJsonPayloads(text: string): Array> { + const trimmed = text.trim(); + if (trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + return isRecord(parsed) ? [parsed] : []; + } catch { + return []; + } + } + + return parseMetaSseFrames(text) + .filter((frame) => frame.data) + .map((frame) => { + try { + const parsed = JSON.parse(frame.data); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } + }) + .filter((frame): frame is Record => !!frame); +} + +function collectRendererTexts(value: unknown, seen: Set, depth = 0): string[] { + if (depth > 8) { + return []; + } + + if (typeof value === "string") { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) { + return []; + } + seen.add(normalized); + return [normalized]; + } + + if (Array.isArray(value)) { + return value.flatMap((item) => collectRendererTexts(item, seen, depth + 1)); + } + + if (!isRecord(value)) { + return []; + } + + const parts: string[] = []; + if (typeof value.text === "string") { + parts.push(...collectRendererTexts(value.text, seen, depth + 1)); + } + + for (const key of [ + "contentRenderer", + "textContent", + "message", + "mediaContent", + "unified_response", + "unifiedResponseContent", + "sections", + "view_model", + "primitive", + "primitives", + "nested_responses", + ]) { + if (key in value) { + parts.push(...collectRendererTexts(value[key], seen, depth + 1)); + } + } + + return parts; +} + +function extractAssistantContent(message: Record): string { + if (typeof message.content === "string" && message.content.length > 0) { + return message.content; + } + + const contentRenderer = isRecord(message.contentRenderer) ? message.contentRenderer : null; + if (!contentRenderer) { + return ""; + } + + const parts = collectRendererTexts(contentRenderer, new Set()); + return parts.join("\n\n").trim(); +} + +function extractAssistantError(message: Record) { + const error = isRecord(message.error) ? message.error : null; + const streamingState = + typeof message.streamingState === "string" ? message.streamingState.toUpperCase() : null; + return { + code: typeof error?.code === "string" ? error.code : null, + message: + typeof error?.message === "string" + ? error.message.trim() + : streamingState === "ERROR" && + typeof message.content === "string" && + message.content.trim() + ? message.content.trim() + : null, + }; +} + +function classifyMetaAiError(errorMessage: string | null, content: string) { + const combined = `${errorMessage || ""}\n${content}`.trim(); + if (!combined) { + return null; + } + + if (/authentication required to send messages|login is required|sign in/i.test(combined)) { + return { + status: 401, + message: "Meta AI auth failed — your meta.ai abra_sess cookie may be missing or expired.", + }; + } + + if (/limit exceeded|rate limit|too many requests/i.test(combined)) { + return { + status: 429, + message: "Meta AI rate limited the session. Wait a moment and retry.", + }; + } + + if (/blocked by our security system|security system/i.test(combined)) { + return { + status: 403, + message: + "Meta AI blocked the request through its web security checks. Refresh the session cookie and retry.", + }; + } + + return null; +} + +function parseMetaAiResponseText(text: string): ParsedMetaAiResponse { + let lastContent = ""; + const deltas: string[] = []; + let errorCode: string | null = null; + let errorMessage: string | null = null; + + for (const payload of readMetaJsonPayloads(text)) { + if (Array.isArray(payload.errors) && payload.errors.length > 0) { + const firstError = payload.errors.find( + (item) => isRecord(item) && typeof item.message === "string" + ); + if (isRecord(firstError) && typeof firstError.message === "string") { + errorMessage = firstError.message.trim(); + } + } + + const data = isRecord(payload.data) ? payload.data : null; + const sendMessageStream = isRecord(data?.sendMessageStream) ? data?.sendMessageStream : null; + if (!sendMessageStream || sendMessageStream.__typename !== "AssistantMessage") { + continue; + } + + const content = extractAssistantContent(sendMessageStream); + if (content && content !== lastContent) { + deltas.push(content.startsWith(lastContent) ? content.slice(lastContent.length) : content); + lastContent = content; + } + + const upstreamError = extractAssistantError(sendMessageStream); + if (upstreamError.message) { + errorMessage = upstreamError.message; + errorCode = upstreamError.code; + } + } + + const classifiedError = classifyMetaAiError(errorMessage, lastContent); + if (classifiedError) { + return { + content: lastContent, + deltas, + errorCode, + errorMessage: classifiedError.message, + status: classifiedError.status, + }; + } + + if (errorMessage) { + return { + content: lastContent, + deltas, + errorCode, + errorMessage: `Meta AI returned an error: ${errorMessage}`, + status: 502, + }; + } + + if (!lastContent) { + return { + content: "", + deltas: [], + errorCode: null, + errorMessage: "Meta AI returned no assistant content", + status: 502, + }; + } + + return { + content: lastContent, + deltas: deltas.filter((delta) => delta.length > 0), + errorCode: null, + errorMessage: null, + status: 200, + }; +} + +function sseChunk(data: unknown): string { + return `data: ${JSON.stringify(data)}\n\n`; +} + +function buildStreamingResponse( + deltas: string[], + model: string, + id: string, + created: number +): ReadableStream { + const encoder = new TextEncoder(); + + return new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { role: "assistant" }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + + for (const delta of deltas) { + if (!delta) continue; + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + delta: { content: delta }, + finish_reason: null, + logprobs: null, + }, + ], + }) + ) + ); + } + + controller.enqueue( + encoder.encode( + sseChunk({ + id, + object: "chat.completion.chunk", + created, + model, + system_fingerprint: null, + choices: [{ index: 0, delta: {}, finish_reason: "stop", logprobs: null }], + }) + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); +} + +function buildNonStreamingResponse(content: string, model: string, id: string, created: number) { + const completionTokens = estimateTokens(content); + + return new Response( + JSON.stringify({ + id, + object: "chat.completion", + created, + model, + system_fingerprint: null, + choices: [ + { + index: 0, + message: { role: "assistant", content }, + finish_reason: "stop", + logprobs: null, + }, + ], + usage: { + prompt_tokens: completionTokens, + completion_tokens: completionTokens, + total_tokens: completionTokens * 2, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); +} + +function buildErrorResponse(status: number, message: string, code?: string | null) { + return new Response( + JSON.stringify({ + error: { + message, + type: "upstream_error", + ...(code ? { code } : {}), + }, + }), + { status, headers: { "Content-Type": "application/json" } } + ); +} + +async function readTextResponse( + body: ReadableStream, + signal?: AbortSignal | null +): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + try { + while (true) { + if (signal?.aborted) { + throw signal.reason ?? new DOMException("Aborted", "AbortError"); + } + + const { value, done } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +export function normalizeMetaAiCookieHeader(apiKey: string): string { + return normalizeSessionCookieHeader(apiKey, META_AI_DEFAULT_COOKIE); +} + +export class MuseSparkWebExecutor extends BaseExecutor { + constructor() { + super("muse-spark-web", { id: "muse-spark-web", baseUrl: META_AI_GRAPHQL_API }); + } + + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { + const messages = (body as Record).messages as + | Array> + | undefined; + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return { + response: buildErrorResponse(400, "Missing or empty messages array", "invalid_request"), + url: META_AI_GRAPHQL_API, + headers: {}, + transformedBody: body, + }; + } + + const prompt = parseOpenAIMessages(messages); + if (!prompt) { + return { + response: buildErrorResponse( + 400, + "Empty query after processing messages", + "invalid_request" + ), + url: META_AI_GRAPHQL_API, + headers: {}, + transformedBody: body, + }; + } + + const transformedBody = buildMetaAiRequestBody(prompt, model); + const cookieHeader = normalizeMetaAiCookieHeader(credentials.apiKey || ""); + const headers: Record = { + Accept: "text/event-stream", + "Content-Type": "application/json", + Cookie: cookieHeader, + Origin: "https://www.meta.ai", + Referer: "https://www.meta.ai/", + "User-Agent": META_AI_USER_AGENT, + }; + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); + + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const combinedSignal = signal ? mergeAbortSignals(signal, timeoutSignal) : timeoutSignal; + + let upstreamResponse: Response; + try { + upstreamResponse = await fetch(META_AI_GRAPHQL_API, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: combinedSignal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.error?.("MUSE-SPARK-WEB", `Fetch failed: ${message}`); + return { + response: buildErrorResponse( + 502, + `Meta AI connection failed: ${message}`, + "meta_ai_fetch_failed" + ), + url: META_AI_GRAPHQL_API, + headers, + transformedBody, + }; + } + + if (!upstreamResponse.ok) { + let message = `Meta AI returned HTTP ${upstreamResponse.status}`; + if (upstreamResponse.status === 401 || upstreamResponse.status === 403) { + message = "Meta AI auth failed — your meta.ai abra_sess cookie may be missing or expired."; + } else if (upstreamResponse.status === 429) { + message = "Meta AI rate limited the session. Wait a moment and retry."; + } + + return { + response: buildErrorResponse( + upstreamResponse.status, + message, + `HTTP_${upstreamResponse.status}` + ), + url: META_AI_GRAPHQL_API, + headers, + transformedBody, + }; + } + + if (!upstreamResponse.body) { + return { + response: buildErrorResponse( + 502, + "Meta AI returned an empty response body", + "meta_ai_empty_body" + ), + url: META_AI_GRAPHQL_API, + headers, + transformedBody, + }; + } + + const responseText = await readTextResponse(upstreamResponse.body, signal); + const parsed = parseMetaAiResponseText(responseText); + if (parsed.status !== 200 || parsed.errorMessage) { + return { + response: buildErrorResponse( + parsed.status, + parsed.errorMessage || "Meta AI returned an unknown error", + parsed.errorCode + ), + url: META_AI_GRAPHQL_API, + headers, + transformedBody, + }; + } + + const id = `chatcmpl-meta-${crypto.randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + const deltas = parsed.deltas.length > 0 ? parsed.deltas : [parsed.content]; + + return { + response: stream + ? new Response(buildStreamingResponse(deltas, model, id, created), { + status: 200, + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + }) + : buildNonStreamingResponse(parsed.content, model, id, created), + url: META_AI_GRAPHQL_API, + headers, + transformedBody, + }; + } +} diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index d608a8febe..264e14a79c 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -143,6 +143,8 @@ import { resolveClaudeCodeCompatibleSessionId, } from "../services/claudeCodeCompatible.ts"; import { setGeminiThoughtSignatureMode } from "../services/geminiThoughtSignatureStore.ts"; +import { fetchLiveProviderLimits } from "@/lib/usage/providerLimits"; +import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage"; function extractMemoryTextFromResponse( response: Record | null | undefined @@ -236,6 +238,29 @@ function extractMemoryTextFromRequestBody( return ""; } +async function maybeSyncClaudeExtraUsageState({ + provider, + connectionId, + providerSpecificData, + log, +}: { + provider: string | null | undefined; + connectionId: string | null | undefined; + providerSpecificData: unknown; + log?: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } | null; +}) { + if (!connectionId || !isClaudeExtraUsageBlockEnabled(provider, providerSpecificData)) { + return; + } + + try { + await fetchLiveProviderLimits(connectionId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log?.debug?.("CLAUDE_USAGE", `Failed to sync Claude extra-usage state: ${message}`); + } +} + function resolveMemoryOwnerId(apiKeyInfo: Record | null): string | null { const rawId = apiKeyInfo?.id; if (typeof rawId === "string" && rawId.trim().length > 0) { @@ -306,6 +331,25 @@ function restoreClaudePassthroughToolNames( }; } +function mergeResponseToolNameMap( + baseToolNameMap: Map | null, + transformedBody: Record | null | undefined +) { + const executorToolNameMap = + transformedBody && transformedBody._toolNameMap instanceof Map + ? (transformedBody._toolNameMap as Map) + : null; + + if (!executorToolNameMap?.size) return baseToolNameMap; + if (!baseToolNameMap?.size) return executorToolNameMap; + + const merged = new Map(baseToolNameMap); + for (const [toolName, originalName] of executorToolNameMap.entries()) { + merged.set(toolName, originalName); + } + return merged; +} + function materializeDeduplicatedExecutionResult>(result: T): T { const snapshot = result && typeof result === "object" @@ -2501,8 +2545,13 @@ export async function handleChatCore({ } } + const responseToolNameMap = mergeResponseToolNameMap( + toolNameMap, + (finalBody as Record | null | undefined) ?? null + ); + if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) { - responseBody = restoreClaudePassthroughToolNames(responseBody, toolNameMap); + responseBody = restoreClaudePassthroughToolNames(responseBody, responseToolNameMap); } reqLogger.logProviderResponse( providerResponse.status, @@ -2521,6 +2570,12 @@ export async function handleChatCore({ if (onRequestSuccess) { await onRequestSuccess(); } + await maybeSyncClaudeExtraUsageState({ + provider, + connectionId, + providerSpecificData: credentials?.providerSpecificData, + log, + }); // Log usage for non-streaming responses const usage = extractUsageFromResponse(responseBody, provider); @@ -2579,7 +2634,7 @@ export async function handleChatCore({ responseBody, responsePayloadFormat, clientResponseFormat, - toolNameMap as Map | null + responseToolNameMap as Map | null ) : responseBody; const memoryExtractionResponse = translatedResponse; @@ -2803,6 +2858,10 @@ export async function handleChatCore({ // Create transform stream with logger for streaming response let transformStream; + const responseToolNameMap = mergeResponseToolNameMap( + toolNameMap, + (finalBody as Record | null | undefined) ?? null + ); // Callback to save call log when stream completes (include responseBody when provided by stream) const onStreamComplete = ({ @@ -2815,6 +2874,15 @@ export async function handleChatCore({ }) => { const cacheUsageLogMeta = buildCacheUsageLogMeta(streamUsage); + if (streamStatus === 200) { + void maybeSyncClaudeExtraUsageState({ + provider, + connectionId, + providerSpecificData: credentials?.providerSpecificData, + log, + }); + } + // Track cache token metrics for streaming responses if (streamUsage && typeof streamUsage === "object") { const inputTokens = streamUsage.prompt_tokens || 0; @@ -2936,7 +3004,7 @@ export async function handleChatCore({ "openai", provider, reqLogger, - toolNameMap, + responseToolNameMap, model, connectionId, body, @@ -2951,7 +3019,7 @@ export async function handleChatCore({ clientResponseFormat, provider, reqLogger, - toolNameMap, + responseToolNameMap, model, connectionId, body, @@ -2963,7 +3031,7 @@ export async function handleChatCore({ transformStream = createPassthroughStreamWithLogger( provider, reqLogger, - toolNameMap, + responseToolNameMap, model, connectionId, body, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 0714f979c9..252395e65d 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1,11 +1,16 @@ import { - COOLDOWN_MS, - BACKOFF_CONFIG, BACKOFF_STEPS_MS, PROVIDER_PROFILES, RateLimitReason, HTTP_STATUS, } from "../config/constants.ts"; +import { + BACKOFF_CONFIG, + COOLDOWN_MS, + calculateBackoffCooldown, + findMatchingErrorRule, + matchErrorRuleByText, +} from "../config/errorConfig.ts"; import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, @@ -707,20 +712,10 @@ export function classifyErrorText(errorText) { if (isAccountDeactivated(errorText)) { return RateLimitReason.AUTH_ERROR; } - if ( - lower.includes("rate limit") || - lower.includes("too many requests") || - lower.includes("rate_limit") - ) { - return RateLimitReason.RATE_LIMIT_EXCEEDED; - } - if ( - lower.includes("capacity") || - lower.includes("overloaded") || - lower.includes("resource exhausted") - ) { - return RateLimitReason.MODEL_CAPACITY; - } + const configuredRule = matchErrorRuleByText(errorText); + if (configuredRule?.reason) return configuredRule.reason; + if (lower.includes("rate_limit")) return RateLimitReason.RATE_LIMIT_EXCEEDED; + if (lower.includes("resource exhausted")) return RateLimitReason.MODEL_CAPACITY; if ( lower.includes("unauthorized") || lower.includes("invalid api key") || @@ -816,8 +811,7 @@ export function getBackoffDuration(failureCount) { * @returns {number} Cooldown in milliseconds */ export function getQuotaCooldown(backoffLevel = 0) { - const cooldown = BACKOFF_CONFIG.base * Math.pow(2, backoffLevel); - return Math.min(cooldown, BACKOFF_CONFIG.max); + return calculateBackoffCooldown(backoffLevel); } /** @@ -937,8 +931,6 @@ export function checkFallbackError( // Check error message FIRST - specific patterns take priority over status codes if (errorText) { - const lowerError = errorStr.toLowerCase(); - // T06 (sub2api #1037): Permanent account deactivation — do NOT retry, mark as permanent failure if (isAccountDeactivated(errorStr)) { return { @@ -971,71 +963,22 @@ export function checkFallbackError( dailyQuotaExhausted: true, }; } - - if (lowerError.includes("no credentials")) { - return { - shouldFallback: true, - cooldownMs: COOLDOWN_MS.notFound, - reason: RateLimitReason.AUTH_ERROR, - }; - } - - if (lowerError.includes("request not allowed")) { - return { - shouldFallback: true, - cooldownMs: COOLDOWN_MS.requestNotAllowed, - baseCooldownMs: COOLDOWN_MS.requestNotAllowed, - reason: RateLimitReason.RATE_LIMIT_EXCEEDED, - }; - } - - // Rate limit keywords - exponential backoff - if ( - lowerError.includes("rate limit") || - lowerError.includes("too many requests") || - lowerError.includes("quota exceeded") || - lowerError.includes("quota will reset") || - lowerError.includes("exhausted your capacity") || - lowerError.includes("quota exhausted") || - lowerError.includes("capacity") || - lowerError.includes("overloaded") - ) { - const reason = classifyErrorText(errorStr); - return buildRetryableFallback(reason); - } } - if (status === HTTP_STATUS.UNAUTHORIZED) { + const configuredRule = findMatchingErrorRule(status, errorStr); + if (configuredRule) { + if (configuredRule.backoff) { + return buildRetryableFallback(configuredRule.reason ?? classifyError(status, errorStr)); + } + const cooldownMs = configuredRule.cooldownMs ?? 0; return { shouldFallback: true, - cooldownMs: 0, - baseCooldownMs: 0, - reason: RateLimitReason.AUTH_ERROR, + cooldownMs, + baseCooldownMs: cooldownMs, + reason: configuredRule.reason ?? RateLimitReason.UNKNOWN, }; } - if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.FORBIDDEN) { - return { - shouldFallback: true, - cooldownMs: 0, - baseCooldownMs: 0, - reason: RateLimitReason.QUOTA_EXHAUSTED, - }; - } - - if (status === HTTP_STATUS.NOT_FOUND) { - return { - shouldFallback: true, - cooldownMs: COOLDOWN_MS.notFound, - reason: RateLimitReason.UNKNOWN, - }; - } - - // 429 - Rate limit with exponential backoff - if (status === HTTP_STATUS.RATE_LIMITED) { - return buildRetryableFallback(RateLimitReason.RATE_LIMIT_EXCEEDED); - } - if (status === HTTP_STATUS.NOT_ACCEPTABLE || retryableStatuses.has(status)) { return buildRetryableFallback(RateLimitReason.SERVER_ERROR); } diff --git a/open-sse/services/antigravityVersion.ts b/open-sse/services/antigravityVersion.ts index 307ebdd421..e299074941 100644 --- a/open-sse/services/antigravityVersion.ts +++ b/open-sse/services/antigravityVersion.ts @@ -5,7 +5,7 @@ const ANTIGRAVITY_GITHUB_RELEASE_URL = export const ANTIGRAVITY_VERSION_CACHE_TTL_MS = 6 * 60 * 60 * 1000; export const ANTIGRAVITY_VERSION_FETCH_TIMEOUT_MS = 5_000; -export const ANTIGRAVITY_FALLBACK_VERSION = "1.22.2"; +export const ANTIGRAVITY_FALLBACK_VERSION = "1.23.2"; type VersionCache = { fetchedAt: number; diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index c59f8c1823..a5379a3f06 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -1,5 +1,5 @@ import { getCorsOrigin } from "./cors.ts"; -import { ERROR_TYPES, DEFAULT_ERROR_MESSAGES } from "../config/constants.ts"; +import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; @@ -10,15 +10,11 @@ import type { ModelCooldownErrorPayload } from "@/types"; * @returns {object} Error response object */ export function buildErrorBody(statusCode, message) { - const errorInfo = - ERROR_TYPES[statusCode] || - (statusCode >= 500 - ? { type: "server_error", code: "internal_server_error" } - : { type: "invalid_request_error", code: "" }); + const errorInfo = getErrorInfo(statusCode); return { error: { - message: message || DEFAULT_ERROR_MESSAGES[statusCode] || "An error occurred", + message: message || getDefaultErrorMessage(statusCode), type: errorInfo.type, code: errorInfo.code, }, diff --git a/package-lock.json b/package-lock.json index 21871618bd..5d543ab22a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "open": "^11.0.0", "ora": "^9.1.0", "pino": "^10.3.1", - "pino-abstract-transport": "^1.2.0", + "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", "react": "19.2.5", "react-dom": "19.2.5", @@ -7299,18 +7299,6 @@ "dev": true, "license": "MIT" }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -10683,30 +10671,12 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -16305,55 +16275,14 @@ } }, "node_modules/pino-abstract-transport": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz", - "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", "license": "MIT", "dependencies": { - "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, - "node_modules/pino-abstract-transport/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/pino-abstract-transport/node_modules/readable-stream": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", - "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/pino-pretty": { "version": "13.1.3", "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", @@ -16378,15 +16307,6 @@ "pino-pretty": "bin.js" } }, - "node_modules/pino-pretty/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, "node_modules/pino-pretty/node_modules/strip-json-comments": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", @@ -16405,15 +16325,6 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "node_modules/pino/node_modules/pino-abstract-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", - "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -16674,15 +16585,6 @@ "license": "MIT", "peer": true }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", diff --git a/package.json b/package.json index d3c2d7acab..81ff09242f 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "open": "^11.0.0", "ora": "^9.1.0", "pino": "^10.3.1", - "pino-abstract-transport": "^1.2.0", + "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", "react": "19.2.5", "react-dom": "19.2.5", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 06f5c3a91f..0e9a64b8d6 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -36,6 +36,33 @@ type CloudflaredTunnelStatus = { logPath: string; }; +type TailscaleTunnelPhase = + | "unsupported" + | "not_installed" + | "needs_login" + | "stopped" + | "running" + | "error"; + +type TailscaleTunnelStatus = { + supported: boolean; + installed: boolean; + managedInstall: boolean; + installSource: string | null; + binaryPath: string | null; + loggedIn: boolean; + daemonRunning: boolean; + running: boolean; + enabled: boolean; + tunnelUrl: string | null; + apiUrl: string | null; + phase: TailscaleTunnelPhase; + platform: string; + brewAvailable: boolean; + lastError: string | null; + pid: number | null; +}; + type TunnelNotice = { type: "success" | "error" | "info"; message: string; @@ -69,6 +96,13 @@ export default function APIPageClient({ machineId }) { const [cloudflaredStatus, setCloudflaredStatus] = useState(null); const [cloudflaredBusy, setCloudflaredBusy] = useState(false); const [cloudflaredNotice, setCloudflaredNotice] = useState(null); + const [tailscaleStatus, setTailscaleStatus] = useState(null); + const [tailscaleBusy, setTailscaleBusy] = useState(false); + const [tailscaleNotice, setTailscaleNotice] = useState(null); + const [showTailscaleInstallModal, setShowTailscaleInstallModal] = useState(false); + const [tailscaleInstallBusy, setTailscaleInstallBusy] = useState(false); + const [tailscaleInstallLog, setTailscaleInstallLog] = useState([]); + const [tailscalePassword, setTailscalePassword] = useState(""); const { copied, copy } = useCopyToClipboard(); @@ -135,6 +169,36 @@ export default function APIPageClient({ machineId }) { [translateOrFallback] ); + const fetchTailscaleStatus = useCallback( + async (silent = false) => { + try { + const res = await fetch("/api/tunnels/tailscale", { cache: "no-store" }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + data?.error || + translateOrFallback("tailscaleRequestFailed", "Failed to load Tailscale status") + ); + } + + setTailscaleStatus(data); + return data as TailscaleTunnelStatus; + } catch (error) { + if (!silent) { + setTailscaleNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("tailscaleRequestFailed", "Failed to load Tailscale status"), + }); + } + return null; + } + }, + [translateOrFallback] + ); + useEffect(() => { Promise.allSettled([ loadCloudSettings(), @@ -142,10 +206,11 @@ export default function APIPageClient({ machineId }) { fetchProtocolStatus(), fetchSearchProviders(), fetchCloudflaredStatus(true), + fetchTailscaleStatus(true), ]).finally(() => { setLoading(false); }); - }, [fetchCloudflaredStatus]); + }, [fetchCloudflaredStatus, fetchTailscaleStatus]); const fetchModels = async () => { try { @@ -268,13 +333,21 @@ export default function APIPageClient({ machineId }) { } }, [cloudflaredNotice]); + useEffect(() => { + if (tailscaleNotice) { + const timer = setTimeout(() => setTailscaleNotice(null), 5000); + return () => clearTimeout(timer); + } + }, [tailscaleNotice]); + useEffect(() => { const interval = setInterval(() => { void fetchProtocolStatus(); void fetchCloudflaredStatus(true); + void fetchTailscaleStatus(true); }, 30000); return () => clearInterval(interval); - }, [fetchCloudflaredStatus]); + }, [fetchCloudflaredStatus, fetchTailscaleStatus]); const dispatchCloudChange = () => { globalThis.dispatchEvent(new Event("cloud-status-changed")); @@ -414,6 +487,276 @@ export default function APIPageClient({ machineId }) { } }; + const waitForTailscale = useCallback( + async ( + predicate: (status: TailscaleTunnelStatus) => boolean, + attempts = 40, + delayMs = 3000 + ) => { + for (let attempt = 0; attempt < attempts; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + const status = await fetchTailscaleStatus(true); + if (status && predicate(status)) { + return status; + } + } + return null; + }, + [fetchTailscaleStatus] + ); + + const requestTailscaleEnable = useCallback(async (payload: Record = {}) => { + const res = await fetch("/api/tunnels/tailscale/enable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + return { res, data }; + }, []); + + const handleTailscaleEnable = useCallback(async () => { + setTailscaleBusy(true); + setTailscaleNotice(null); + + try { + let { res, data } = await requestTailscaleEnable(); + if (!res.ok) { + throw new Error( + data?.error || + translateOrFallback("tailscaleEnableFailed", "Failed to enable Tailscale Funnel") + ); + } + + if (data?.needsLogin && data?.authUrl) { + window.open(data.authUrl, "tailscale_auth", "width=680,height=780"); + setTailscaleNotice({ + type: "info", + message: translateOrFallback( + "tailscaleWaitingForLogin", + "Complete the Tailscale login in the opened browser tab. OmniRoute will retry automatically." + ), + }); + + const loggedIn = await waitForTailscale((status) => status.loggedIn); + if (!loggedIn) { + throw new Error( + translateOrFallback("tailscaleLoginTimedOut", "Timed out waiting for Tailscale login") + ); + } + + ({ res, data } = await requestTailscaleEnable()); + if (!res.ok) { + throw new Error( + data?.error || + translateOrFallback("tailscaleEnableFailed", "Failed to enable Tailscale Funnel") + ); + } + } + + if (data?.funnelNotEnabled && data?.enableUrl) { + window.open(data.enableUrl, "tailscale_funnel", "width=680,height=780"); + setTailscaleNotice({ + type: "info", + message: translateOrFallback( + "tailscaleWaitingForFunnel", + "Enable Funnel for this device in the opened browser tab. OmniRoute will keep polling." + ), + }); + + let enabled = null; + for (let attempt = 0; attempt < 40; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 3000)); + const next = await requestTailscaleEnable(); + if (!next.res.ok) { + throw new Error( + next.data?.error || + translateOrFallback("tailscaleEnableFailed", "Failed to enable Tailscale Funnel") + ); + } + if (next.data?.success) { + enabled = next; + break; + } + if (!next.data?.funnelNotEnabled) { + enabled = next; + break; + } + } + + if (!enabled?.data?.success) { + throw new Error( + translateOrFallback( + "tailscaleFunnelTimedOut", + "Timed out waiting for Tailscale Funnel to be enabled" + ) + ); + } + + data = enabled.data; + } + + if (!data?.success) { + throw new Error( + data?.error || + translateOrFallback("tailscaleEnableFailed", "Failed to enable Tailscale Funnel") + ); + } + + if (data?.status) { + setTailscaleStatus(data.status); + } + setTailscaleNotice({ + type: "success", + message: translateOrFallback("tailscaleStarted", "Tailscale Funnel enabled"), + }); + } catch (error) { + setTailscaleNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("tailscaleEnableFailed", "Failed to enable Tailscale Funnel"), + }); + } finally { + setTailscaleBusy(false); + await fetchTailscaleStatus(true); + } + }, [fetchTailscaleStatus, requestTailscaleEnable, translateOrFallback, waitForTailscale]); + + const handleTailscaleDisable = useCallback(async () => { + setTailscaleBusy(true); + setTailscaleNotice(null); + + try { + const res = await fetch("/api/tunnels/tailscale/disable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error( + data?.error || + translateOrFallback("tailscaleDisableFailed", "Failed to disable Tailscale Funnel") + ); + } + + if (data?.status) { + setTailscaleStatus(data.status); + } + setTailscaleNotice({ + type: "success", + message: translateOrFallback("tailscaleStopped", "Tailscale Funnel disabled"), + }); + } catch (error) { + setTailscaleNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("tailscaleDisableFailed", "Failed to disable Tailscale Funnel"), + }); + } finally { + setTailscaleBusy(false); + await fetchTailscaleStatus(true); + } + }, [fetchTailscaleStatus, translateOrFallback]); + + const handleTailscaleInstall = useCallback(async () => { + setTailscaleInstallBusy(true); + setTailscaleInstallLog([]); + setTailscaleNotice(null); + + try { + const res = await fetch("/api/tunnels/tailscale/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sudoPassword: tailscalePassword || undefined }), + }); + + if (!res.body) { + throw new Error( + translateOrFallback("tailscaleInstallFailed", "Failed to install Tailscale") + ); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let installSucceeded = false; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const parts = buffer.split("\n\n"); + buffer = parts.pop() || ""; + + for (const part of parts) { + const lines = part.split("\n"); + let eventName = "progress"; + let payload: Record | null = null; + + for (const line of lines) { + if (line.startsWith("event: ")) { + eventName = line.slice(7).trim(); + } else if (line.startsWith("data: ")) { + payload = JSON.parse(line.slice(6)); + } + } + + if (!payload) continue; + + if (eventName === "progress") { + const message = + typeof payload.message === "string" + ? payload.message + : translateOrFallback("tailscaleInstallProgress", "Working..."); + setTailscaleInstallLog((current) => [...current.slice(-79), message]); + } else if (eventName === "done") { + installSucceeded = true; + if (payload.status) { + setTailscaleStatus(payload.status as TailscaleTunnelStatus); + } + } else if (eventName === "error") { + throw new Error( + typeof payload.error === "string" + ? payload.error + : translateOrFallback("tailscaleInstallFailed", "Failed to install Tailscale") + ); + } + } + } + + if (!installSucceeded) { + throw new Error( + translateOrFallback("tailscaleInstallFailed", "Failed to install Tailscale") + ); + } + + setShowTailscaleInstallModal(false); + setTailscalePassword(""); + setTailscaleNotice({ + type: "success", + message: translateOrFallback("tailscaleInstalled", "Tailscale installed successfully"), + }); + await fetchTailscaleStatus(true); + await handleTailscaleEnable(); + } catch (error) { + setTailscaleNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("tailscaleInstallFailed", "Failed to install Tailscale"), + }); + } finally { + setTailscaleInstallBusy(false); + } + }, [fetchTailscaleStatus, handleTailscaleEnable, tailscalePassword, translateOrFallback]); + const [baseUrl, setBaseUrl] = useState("/v1"); const normalizedCloudBaseUrl = cloudBaseUrl ? resolvedMachineId && !cloudBaseUrl.endsWith(`/${resolvedMachineId}`) @@ -481,6 +824,44 @@ export default function APIPageClient({ machineId }) { "cloudflaredUrlNotice", "Creates a temporary Cloudflare Quick Tunnel. The URL changes after every restart." ); + const tailscalePhase = tailscaleStatus?.phase || "not_installed"; + const tailscalePhaseMeta: Record = { + running: { + label: translateOrFallback("tailscaleRunning", "Running"), + className: "bg-green-500/10 border-green-500/30 text-green-400", + }, + needs_login: { + label: translateOrFallback("tailscaleNeedsLogin", "Needs Login"), + className: "bg-blue-500/10 border-blue-500/30 text-blue-400", + }, + stopped: { + label: translateOrFallback("tailscaleStoppedState", "Stopped"), + className: "bg-surface border-border/70 text-text-muted", + }, + not_installed: { + label: translateOrFallback("tailscaleNotInstalled", "Not installed"), + className: "bg-surface border-border/70 text-text-muted", + }, + unsupported: { + label: translateOrFallback("tailscaleUnsupported", "Unsupported"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + error: { + label: translateOrFallback("tailscaleError", "Error"), + className: "bg-red-500/10 border-red-500/30 text-red-400", + }, + }; + const tailscaleActionLabel = tailscaleStatus?.running + ? translateOrFallback("tailscaleDisable", "Stop Funnel") + : !tailscaleStatus?.installed + ? translateOrFallback("tailscaleInstallAndEnable", "Install & Enable") + : !tailscaleStatus?.loggedIn + ? translateOrFallback("tailscaleLoginAndEnable", "Login & Enable") + : translateOrFallback("tailscaleEnable", "Enable Funnel"); + const tailscaleUrlNotice = translateOrFallback( + "tailscaleUrlNotice", + "Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use." + ); return (
@@ -676,6 +1057,120 @@ export default function APIPageClient({ machineId }) { )}
+ +
+
+
+
+
+

+ {translateOrFallback("tailscaleTitle", "Tailscale Funnel")} +

+ + {tailscalePhaseMeta[tailscalePhase].label} + +
+
+ + {tailscaleStatus?.supported !== false && ( + + )} +
+ + {tailscaleNotice && ( +
+ + {tailscaleNotice.type === "success" + ? "check_circle" + : tailscaleNotice.type === "info" + ? "info" + : "error"} + + {tailscaleNotice.message} + +
+ )} + +

{tailscaleUrlNotice}

+ {tailscaleStatus?.phase === "needs_login" && ( +

+ {translateOrFallback( + "tailscaleNeedsLoginHint", + "Authenticate this machine with Tailscale, then enable Funnel." + )} +

+ )} +
+ + +
+ {tailscaleStatus?.binaryPath && ( +

+ {translateOrFallback("tailscaleBinaryPath", "Binary: {path}", { + path: tailscaleStatus.binaryPath, + })} +

+ )} + {tailscaleStatus?.lastError && ( +

+ {translateOrFallback("tailscaleLastError", "Last error: {error}", { + error: tailscaleStatus.lastError, + })} +

+ )} +
+
@@ -1320,6 +1815,72 @@ export default function APIPageClient({ machineId }) { + + !tailscaleInstallBusy && setShowTailscaleInstallModal(false)} + > +
+
+

+ {translateOrFallback( + "tailscaleInstallIntro", + "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel." + )} +

+

+ {translateOrFallback( + "tailscaleInstallPasswordHint", + "On macOS and Linux, sudo may be required for the package install and daemon start." + )} +

+
+ + setTailscalePassword(event.target.value)} + placeholder={translateOrFallback("tailscaleSudoPlaceholder", "Optional sudo password")} + disabled={tailscaleInstallBusy} + /> + + {tailscaleInstallLog.length > 0 && ( +
+
+                {tailscaleInstallLog.join("\n")}
+              
+
+ )} + +
+ + +
+
+
{/* Provider Models Popup */} {selectedProvider && ( void; onToggleActive: (isActive?: boolean) => void | Promise; onToggleRateLimit: (enabled?: boolean) => void; + onToggleClaudeExtraUsage?: (enabled?: boolean) => void; onToggleCodex5h?: (enabled?: boolean) => void; onToggleCodexWeekly?: (enabled?: boolean) => void; isCcCompatible?: boolean; @@ -1014,8 +1017,8 @@ export default function ProviderDetailPage() { const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); - // For Gemini: always use synced API models (empty if no keys added yet) - // For other providers: merge registry models with custom/imported models (deduped) + // Prefer synced API-discovered models when available, then merge built-ins + // and user-managed custom/imported models without duplicating IDs. const models = useMemo(() => { if (providerId === "gemini") { return syncedAvailableModels.map((model: any) => ({ @@ -1029,17 +1032,23 @@ export default function ProviderDetailPage() { source: "system", })); - if (!modelMeta.customModels || modelMeta.customModels.length === 0) return builtInModels; - const registryIds = new Set(builtInModels.map((m) => m.id)); + const syncedExtras = syncedAvailableModels + .filter((model: any) => model?.id && !registryIds.has(model.id)) + .map((model: any) => ({ + id: model.id, + name: model.name || model.id, + source: "api-sync", + })); + const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); const customExtras = modelMeta.customModels - .filter((cm: any) => cm.id && !registryIds.has(cm.id)) + .filter((cm: any) => cm.id && !knownIds.has(cm.id)) .map((cm: any) => ({ id: cm.id, name: cm.name || cm.id, source: cm.source === "api-sync" ? "api-sync" : "custom", })); - return [...builtInModels, ...customExtras]; + return [...builtInModels, ...syncedExtras, ...customExtras]; }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); const providerAlias = getProviderAlias(providerId); const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter"; @@ -1119,19 +1128,21 @@ export default function ProviderDetailPage() { customModels: data.models || [], modelCompatOverrides: data.modelCompatOverrides || [], }); - // Fetch synced available models for Gemini - if (providerId === "gemini") { - try { - const syncRes = await fetch("/api/synced-available-models?provider=gemini", { + try { + const syncRes = await fetch( + `/api/synced-available-models?provider=${encodeURIComponent(providerId)}`, + { cache: "no-store", - }); - if (syncRes.ok) { - const syncData = await syncRes.json(); - setSyncedAvailableModels(syncData.models || []); } - } catch { - // Non-critical + ); + if (syncRes.ok) { + const syncData = await syncRes.json(); + setSyncedAvailableModels(syncData.models || []); + } else { + setSyncedAvailableModels([]); } + } catch { + setSyncedAvailableModels([]); } } catch (e) { console.error("fetchProviderModelMeta", e); @@ -1438,6 +1449,66 @@ export default function ProviderDetailPage() { } }; + const handleToggleClaudeExtraUsage = async (connectionId, enabled) => { + try { + const target = connections.find((connection) => connection.id === connectionId); + if (!target) return; + + const providerSpecificData = + target.providerSpecificData && typeof target.providerSpecificData === "object" + ? target.providerSpecificData + : {}; + + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + ...providerSpecificData, + blockExtraUsage: enabled, + }, + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify.error(data.error || "Failed to update Claude extra-usage policy"); + return; + } + + setConnections((prev) => + prev.map((connection) => + connection.id === connectionId + ? { + ...connection, + providerSpecificData: { + ...(connection.providerSpecificData || {}), + blockExtraUsage: enabled, + }, + ...(!enabled && connection.lastErrorSource === "extra_usage" + ? { + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + } + : {}), + } + : connection + ) + ); + notify.success( + enabled ? "Claude extra-usage blocking enabled" : "Claude extra-usage blocking disabled" + ); + } catch (error) { + console.error("Error toggling Claude extra-usage policy:", error); + notify.error("Failed to update Claude extra-usage policy"); + } + }; + const [cpaProviderEnabled, setCpaProviderEnabled] = useState(false); // Load upstream proxy config for this provider on mount @@ -1801,7 +1872,7 @@ export default function ProviderDetailPage() { }); try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const res = await fetch(`/api/providers/${activeConnection.id}/models?refresh=true`); const data = await res.json(); if (!res.ok) { setImportProgress((prev) => ({ @@ -2762,6 +2833,7 @@ export default function ProviderDetailPage() { key={conn.id} connection={conn} isOAuth={conn.authType === "oauth"} + isClaude={providerId === "claude"} isFirst={index === 0} isLast={index === sorted.length - 1} onMoveUp={() => handleSwapPriority(conn, sorted[index - 1])} @@ -2770,6 +2842,9 @@ export default function ProviderDetailPage() { handleUpdateConnectionStatus(conn.id, isActive) } onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled)} + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } isCodex={providerId === "codex"} isCcCompatible={isCcCompatible} cliproxyapiEnabled={cpaProviderEnabled} @@ -2874,6 +2949,7 @@ export default function ProviderDetailPage() { key={conn.id} connection={conn} isOAuth={conn.authType === "oauth"} + isClaude={providerId === "claude"} isFirst={gi === 0 && index === 0} isLast={ gi === groupKeys.length - 1 && index === groupConns.length - 1 @@ -2890,6 +2966,9 @@ export default function ProviderDetailPage() { onToggleRateLimit={(enabled) => handleToggleRateLimit(conn.id, enabled) } + onToggleClaudeExtraUsage={(enabled) => + handleToggleClaudeExtraUsage(conn.id, enabled) + } isCodex={providerId === "codex"} onToggleCodex5h={(enabled) => handleToggleCodexLimit(conn.id, "use5h", enabled) @@ -4422,7 +4501,7 @@ function CompatibleModelsSection({ await onImportWithProgress( // fetchModels callback async () => { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const res = await fetch(`/api/providers/${activeConnection.id}/models?refresh=true`); const data = await res.json(); if (!res.ok) throw new Error(data.error || t("failedImportModels")); return data; @@ -4853,6 +4932,7 @@ function getStatusPresentation(connection, effectiveStatus, isCooldown, t) { function ConnectionRow({ connection, isOAuth, + isClaude, isCodex, isCcCompatible, cliproxyapiEnabled, @@ -4862,6 +4942,7 @@ function ConnectionRow({ onMoveDown, onToggleActive, onToggleRateLimit, + onToggleClaudeExtraUsage, onToggleCodex5h, onToggleCodexWeekly, onToggleCliproxyapiMode, @@ -4956,6 +5037,9 @@ function ConnectionRow({ const normalizedCodexPolicy = normalizeCodexLimitPolicy(codexPolicy); const codex5hEnabled = normalizedCodexPolicy.use5h; const codexWeeklyEnabled = normalizedCodexPolicy.useWeekly; + const claudeBlockExtraUsageEnabled = isClaude + ? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData) + : false; const cliproxyapiDeepMode = !!cliproxyapiEnabled; return ( @@ -5046,6 +5130,23 @@ function ConnectionRow({ shield {rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")} + {isClaude && ( + <> + | + + + )} {isCcCompatible && ( <> | @@ -5247,6 +5348,7 @@ ConnectionRow.propTypes = { providerSpecificData: PropTypes.object, }).isRequired, isOAuth: PropTypes.bool.isRequired, + isClaude: PropTypes.bool, isCodex: PropTypes.bool, isFirst: PropTypes.bool.isRequired, isLast: PropTypes.bool.isRequired, @@ -5254,6 +5356,7 @@ ConnectionRow.propTypes = { onMoveDown: PropTypes.func.isRequired, onToggleActive: PropTypes.func.isRequired, onToggleRateLimit: PropTypes.func.isRequired, + onToggleClaudeExtraUsage: PropTypes.func, onToggleCodex5h: PropTypes.func, onToggleCodexWeekly: PropTypes.func, isCcCompatible: PropTypes.bool, @@ -5394,7 +5497,9 @@ function AddApiKeyModal({ const isGooglePse = provider === "google-pse-search"; const isGrokWeb = provider === "grok-web"; const isPerplexityWeb = provider === "perplexity-web"; - const isWebSessionProvider = isGrokWeb || isPerplexityWeb; + const isBlackboxWeb = provider === "blackbox-web"; + const isMuseSparkWeb = provider === "muse-spark-web"; + const isWebSessionProvider = isGrokWeb || isPerplexityWeb || isBlackboxWeb || isMuseSparkWeb; const apiKeyOptional = isSearxng; const [formData, setFormData] = useState({ @@ -5583,11 +5688,15 @@ function AddApiKeyModal({ ? "Paste your sso cookie value from grok.com" : isPerplexityWeb ? "Paste your __Secure-next-auth.session-token value" - : isSearxng - ? "Optional" - : isQoder - ? "Paste your Qoder Personal Access Token" - : undefined + : isBlackboxWeb + ? "Paste your __Secure-authjs.session-token value" + : isMuseSparkWeb + ? "Paste your abra_sess value" + : isSearxng + ? "Optional" + : isQoder + ? "Paste your Qoder Personal Access Token" + : undefined } hint={ isQoder @@ -5596,9 +5705,13 @@ function AddApiKeyModal({ ? "Paste the sso cookie from grok.com. A full 'sso=...' value also works." : isPerplexityWeb ? "Paste the __Secure-next-auth.session-token cookie from perplexity.ai." - : isSearxng - ? "Optional. Leave blank if your SearXNG instance does not require authentication." - : undefined + : isBlackboxWeb + ? "Paste the __Secure-authjs.session-token cookie from app.blackbox.ai. A full cookie header also works." + : isMuseSparkWeb + ? "Paste the abra_sess cookie from meta.ai. A full cookie header also works." + : isSearxng + ? "Optional. Leave blank if your SearXNG instance does not require authentication." + : undefined } />
@@ -5845,6 +5958,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: false, consoleApiKey: "", ccCompatibleContext1m: false, + blockExtraUsage: + connection?.provider === "claude" + ? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData) + : false, passthroughModels: connection?.providerSpecificData?.passthroughModels === true, }); const [testing, setTesting] = useState(false); @@ -5865,6 +5982,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isGlm = connection?.provider === "glm" || connection?.provider === "glmt"; const isCloudflare = connection?.provider === "cloudflare-ai"; const isCodex = connection?.provider === "codex"; + const isClaude = connection?.provider === "claude"; const isSearxng = connection?.provider === "searxng-search"; const isGooglePse = connection?.provider === "google-pse-search"; const apiKeyOptional = isSearxng; @@ -5913,6 +6031,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true, consoleApiKey: existingConsoleApiKey, ccCompatibleContext1m: ccRequestDefaults.context1m, + blockExtraUsage: isClaudeExtraUsageBlockEnabled( + connection.provider, + connection.providerSpecificData + ), passthroughModels: connection?.providerSpecificData?.passthroughModels === true, }); // Load existing extra keys from providerSpecificData @@ -6102,6 +6224,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec tags: parseRoutingTagsInput(formData.routingTags), excludedModels: parseExcludedModelsInput(formData.excludedModels), }; + if (isClaude) { + updates.providerSpecificData.blockExtraUsage = formData.blockExtraUsage; + } if (isCodex) { updates.providerSpecificData.requestDefaults = { reasoningEffort: formData.codexReasoningEffort, @@ -6184,6 +6309,16 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec />
)} + {isClaude && ( +
+ setFormData({ ...formData, blockExtraUsage: checked })} + label="Block Claude Extra Usage" + description="When enabled, OmniRoute marks this Claude Code account unavailable as soon as the usage API reports `extra_usage.queued`, so fallback switches to another account before extra pay-as-you-go charges continue." + /> +
+ )} {isCcCompatible && (
; @@ -470,6 +475,7 @@ export async function GET( // Check if we should exclude hidden models (used by MCP tools to prevent hidden model leaks) const { searchParams } = new URL(request.url); const excludeHidden = searchParams.get("excludeHidden") === "true"; + const refresh = searchParams.get("refresh") === "true"; const connection = await getProviderConnectionById(id); @@ -498,10 +504,126 @@ export async function GET( const connectionId = typeof connection.id === "string" ? connection.id : id; const apiKey = typeof connection.apiKey === "string" ? connection.apiKey : ""; const accessToken = typeof connection.accessToken === "string" ? connection.accessToken : ""; + const autoFetchModels = isAutoFetchModelsEnabled(connection.providerSpecificData); + const cachedDiscoveryModels = await getCachedDiscoveredModels(provider, connectionId); + + const toLocalCatalogModels = () => { + const localCatalog = getStaticModelsForProvider(provider) || PROVIDER_MODELS[provider] || []; + return localCatalog.map((model: any) => ({ + id: model.id, + name: model.name || model.id, + owned_by: provider, + })); + }; + + const buildCachedDiscoveryResponse = (warning?: string) => + buildResponse({ + provider, + connectionId, + models: cachedDiscoveryModels, + source: "cache", + ...(warning ? { warning } : {}), + }); + + const buildLocalCatalogResponse = (warning?: string) => { + const localModels = toLocalCatalogModels(); + if (localModels.length === 0) return null; + return buildResponse({ + provider, + connectionId, + models: localModels, + source: "local_catalog", + ...(warning ? { warning } : {}), + }); + }; + + const buildDiscoveryFallbackResponse = ({ + cacheWarning = "API unavailable — using cached catalog", + localWarning = "API unavailable — using local catalog", + }: { + cacheWarning?: string; + localWarning?: string; + } = {}) => { + if (cachedDiscoveryModels.length > 0) { + return buildCachedDiscoveryResponse(cacheWarning); + } + return buildLocalCatalogResponse(localWarning); + }; + + const buildDiscoveryErrorFallbackResponse = ( + error: unknown, + warnings?: { + cacheWarning?: string; + localWarning?: string; + } + ) => { + const status = getSafeOutboundFetchErrorStatus(error); + if (status === 400) return null; + return buildDiscoveryFallbackResponse(warnings); + }; + + const maybeReturnCachedDiscovery = () => { + if (!refresh && cachedDiscoveryModels.length > 0) { + return buildCachedDiscoveryResponse(); + } + return null; + }; + + const maybeReturnAutoFetchDisabled = () => { + if (refresh || autoFetchModels) return null; + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Auto-fetch disabled — using cached catalog", + localWarning: "Auto-fetch disabled — using local catalog", + }); + if (fallback) return fallback; + return buildResponse({ + provider, + connectionId, + models: [], + source: "local_catalog", + warning: "Auto-fetch disabled — no cached models available", + }); + }; + + const buildApiDiscoveryResponse = async (models: any[]) => { + if (Array.isArray(models) && models.length > 0) { + await persistDiscoveredModels(provider, connectionId, models); + return buildResponse({ + provider, + connectionId, + models, + source: "api", + }); + } + + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "No remote models discovered — using cached catalog", + localWarning: "No remote models discovered — using local catalog", + }); + if (fallback) return fallback; + + return buildResponse({ + provider, + connectionId, + models: [], + source: "api", + }); + }; if (isOpenAICompatibleProvider(provider)) { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + const baseUrl = getProviderBaseUrl(connection.providerSpecificData); if (!baseUrl) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Base URL unavailable — using cached catalog", + localWarning: "Base URL unavailable — using local catalog", + }); + if (fallback) return fallback; return NextResponse.json( { error: "No base URL configured for OpenAI compatible provider" }, { status: 400 } @@ -563,6 +685,18 @@ export async function GET( // If all endpoints failed (but not because of auth), fallback to local catalog if (!models) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: + lastErrorStatus === 401 || lastErrorStatus === 403 + ? `Auth failed (${lastErrorStatus}) — using cached catalog` + : "API unavailable — using cached catalog", + localWarning: + lastErrorStatus === 401 || lastErrorStatus === 403 + ? `Auth failed (${lastErrorStatus}) — using local catalog` + : "API unavailable — using local catalog", + }); + if (fallback) return fallback; + if (lastErrorStatus === 401 || lastErrorStatus === 403) { return NextResponse.json( { error: `Auth failed: ${lastErrorStatus}` }, @@ -571,29 +705,16 @@ export async function GET( } console.warn(`[models] All endpoints failed for ${provider}, using local catalog`); - const localModels = PROVIDER_MODELS[provider] || []; - models = localModels.map((m: any) => ({ - id: m.id, - name: m.name || m.id, - owned_by: provider, - })); + models = toLocalCatalogModels(); + return buildResponse({ + provider, + connectionId, + models, + source: "local_catalog", + warning: "API unavailable — using local catalog", + }); } - - // Track source for MCP tool T39 requirement - const source = - models === null || (models && models.length > 0 && models[0].owned_by === provider) - ? "local_catalog" - : "api"; - - return buildResponse({ - provider, - connectionId, - models, - source, - ...(source === "local_catalog" - ? { warning: "API unavailable — using cached catalog" } - : {}), - }); + return buildApiDiscoveryResponse(models); } if (provider === "claude") { @@ -605,21 +726,36 @@ export async function GET( } if (provider === "glm" || provider === "glmt") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + const url = getGlmModelsUrl(connection.providerSpecificData); const token = apiKey || accessToken; - const response = await safeOutboundFetch(url, { - ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, - guard: getProviderOutboundGuard(), - proxyConfig: proxy, - method: "GET", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + let response: Response; + try { + response = await safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (error) { + const fallback = buildDiscoveryErrorFallbackResponse(error); + if (fallback) return fallback; + throw error; + } if (!response.ok) { + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; return NextResponse.json( { error: `Failed to fetch models: ${response.status}` }, { status: response.status } @@ -629,10 +765,16 @@ export async function GET( const data = await response.json(); const models = data.data || data.models || []; - return buildResponse({ provider, connectionId, models }); + return buildApiDiscoveryResponse(models); } if (provider === "gemini-cli") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + // Gemini CLI doesn't have a /models endpoint. Instead, query the quota // endpoint to discover available models from the quota buckets. if (!accessToken) { @@ -671,6 +813,8 @@ export async function GET( if (!quotaRes.ok) { const errText = await quotaRes.text(); console.log(`[models] Gemini CLI quota fetch failed (${quotaRes.status}):`, errText); + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; return NextResponse.json( { error: `Failed to fetch Gemini CLI models: ${quotaRes.status}` }, { status: quotaRes.status } @@ -688,25 +832,38 @@ export async function GET( owned_by: "google", })); - return buildResponse({ provider, connectionId, models }); + return buildApiDiscoveryResponse(models); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); console.log("[models] Gemini CLI model fetch error:", msg); + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; return NextResponse.json({ error: "Failed to fetch Gemini CLI models" }, { status: 500 }); } } if (provider === "antigravity") { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + const staticModels = STATIC_MODEL_PROVIDERS.antigravity(); const discoveryUrls = getAntigravityModelsDiscoveryUrls(); if (!accessToken) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "OAuth token unavailable — using cached catalog", + localWarning: "OAuth token unavailable — using local catalog", + }); + if (fallback) return fallback; return buildResponse({ provider, connectionId, models: staticModels, source: "local_catalog", - warning: "OAuth token unavailable — using cached catalog", + warning: "OAuth token unavailable — using local catalog", }); } @@ -735,12 +892,7 @@ export async function GET( mapAntigravityModelForClient ); if (remoteModels.length > 0) { - return buildResponse({ - provider, - connectionId, - models: remoteModels, - source: "api", - }); + return buildApiDiscoveryResponse(remoteModels); } } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -748,16 +900,25 @@ export async function GET( } } + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; + return buildResponse({ provider, connectionId, models: staticModels, source: "local_catalog", - warning: "API unavailable — using cached catalog", + warning: "API unavailable — using local catalog", }); } if (isAnthropicCompatibleProvider(provider)) { + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + if (isClaudeCodeCompatibleProvider(provider)) { return NextResponse.json( { error: `Provider ${provider} does not support models listing` }, @@ -767,6 +928,11 @@ export async function GET( let baseUrl = getProviderBaseUrl(connection.providerSpecificData); if (!baseUrl) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "Base URL unavailable — using cached catalog", + localWarning: "Base URL unavailable — using local catalog", + }); + if (fallback) return fallback; return NextResponse.json( { error: "No base URL configured for Anthropic compatible provider" }, { status: 400 } @@ -783,22 +949,31 @@ export async function GET( const modelsPath = toNonEmptyString(psd.modelsPath) || "/models"; const url = `${baseUrl}${modelsPath}`; const token = accessToken || apiKey; - const response = await safeOutboundFetch(url, { - ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, - guard: getProviderOutboundGuard(), - proxyConfig: proxy, - method: "GET", - headers: { - "Content-Type": "application/json", - ...(apiKey ? { "x-api-key": apiKey } : {}), - "anthropic-version": "2023-06-01", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + let response: Response; + try { + response = await safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + method: "GET", + headers: { + "Content-Type": "application/json", + ...(apiKey ? { "x-api-key": apiKey } : {}), + "anthropic-version": "2023-06-01", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch (error) { + const fallback = buildDiscoveryErrorFallbackResponse(error); + if (fallback) return fallback; + throw error; + } if (!response.ok) { const errorText = await response.text(); console.log(`Error fetching models from ${provider}:`, errorText); + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; return NextResponse.json( { error: `Failed to fetch models: ${response.status}` }, { status: response.status } @@ -808,11 +983,7 @@ export async function GET( const data = await response.json(); const models = data.data || data.models || []; - return buildResponse({ - provider, - connectionId, - models, - }); + return buildApiDiscoveryResponse(models); } // Static model providers (no remote /models API) @@ -855,7 +1026,7 @@ export async function GET( owned_by: provider, })), source: "local_catalog", - warning: "API unavailable — using cached catalog", + warning: "API unavailable — using local catalog", }); } if (!config) { @@ -865,9 +1036,20 @@ export async function GET( ); } + const cachedResponse = maybeReturnCachedDiscovery(); + if (cachedResponse) return cachedResponse; + + const autoFetchDisabledResponse = maybeReturnAutoFetchDisabled(); + if (autoFetchDisabledResponse) return autoFetchDisabledResponse; + // Get auth token const token = accessToken || apiKey; if (!token) { + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: "No token configured — using cached catalog", + localWarning: "No token configured — using local catalog", + }); + if (fallback) return fallback; return NextResponse.json( { error: @@ -907,18 +1089,27 @@ export async function GET( while (pageUrl && pageCount < MAX_PAGES) { pageCount++; - const response = await safeOutboundFetch(pageUrl, { - ...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination, - guard: getProviderOutboundGuard(), - proxyConfig: proxy, - // Ollama Cloud /v1/models returns 301 redirects (#1381) - ...(provider === "ollama-cloud" ? { allowRedirect: true } : {}), - ...fetchOptions, - }); + let response: Response; + try { + response = await safeOutboundFetch(pageUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.modelsPagination, + guard: getProviderOutboundGuard(), + proxyConfig: proxy, + // Ollama Cloud /v1/models returns 301 redirects (#1381) + ...(provider === "ollama-cloud" ? { allowRedirect: true } : {}), + ...fetchOptions, + }); + } catch (error) { + const fallback = buildDiscoveryErrorFallbackResponse(error); + if (fallback) return fallback; + throw error; + } if (!response.ok) { const errorText = await response.text(); console.log(`Error fetching models from ${provider}:`, errorText); + const fallback = buildDiscoveryFallbackResponse(); + if (fallback) return fallback; return NextResponse.json( { error: `Failed to fetch models: ${response.status}` }, { status: response.status } @@ -948,11 +1139,7 @@ export async function GET( ); } - return buildResponse({ - provider, - connectionId, - models: allModels, - }); + return buildApiDiscoveryResponse(allModels); } catch (error) { const status = getSafeOutboundFetchErrorStatus(error); if (status) { diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 88ee650319..9755402ac5 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -15,6 +15,10 @@ import { syncToCloud } from "@/lib/cloudSync"; import { updateProviderConnectionSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults"; +import { + buildClaudeExtraUsageStateClearUpdate, + isClaudeExtraUsageBlockEnabled, +} from "@/lib/providers/claudeExtraUsage"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; function normalizeCodexLimitPolicy( @@ -161,6 +165,23 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: updateData.providerSpecificData = normalizeProviderSpecificData(existing.provider, mergedPsd) || {}; + + if (!isClaudeExtraUsageBlockEnabled(existing.provider, updateData.providerSpecificData)) { + const clearExtraUsageUpdate = buildClaudeExtraUsageStateClearUpdate({ + provider: existing.provider, + testStatus: existing.testStatus, + lastError: existing.lastError, + lastErrorAt: existing.lastErrorAt, + lastErrorType: existing.lastErrorType, + lastErrorSource: existing.lastErrorSource, + errorCode: existing.errorCode, + rateLimitedUntil: existing.rateLimitedUntil, + backoffLevel: existing.backoffLevel, + }); + if (clearExtraUsageUpdate) { + Object.assign(updateData, clearExtraUsageUpdate); + } + } } const updated = await updateProviderConnection(id, updateData); @@ -223,13 +244,14 @@ export async function DELETE(request: Request, { params }: { params: Promise<{ i } // Clean up synced available models for this connection - if (connection.provider === "gemini") { - try { - const { deleteSyncedAvailableModelsForConnection } = await import("@/lib/db/models"); - await deleteSyncedAvailableModelsForConnection("gemini", id); - } catch (e) { - console.error("Failed to clean up synced models for deleted gemini connection:", e); - } + try { + const { deleteSyncedAvailableModelsForConnection } = await import("@/lib/db/models"); + await deleteSyncedAvailableModelsForConnection(connection.provider, id); + } catch (e) { + console.error( + `Failed to clean up synced models for deleted ${connection.provider} connection:`, + e + ); } // Auto sync to Cloud if enabled diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 8dd1c46e35..808cbf8ace 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -9,6 +9,7 @@ import { syncManagedAvailableModelAliases, usesManagedAvailableModels, } from "@/lib/providerModels/managedAvailableModels"; +import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; import { saveCallLog } from "@/lib/usage/callLogs"; import { isAuthenticated } from "@/shared/utils/apiAuth"; import { @@ -117,8 +118,9 @@ function getModelSyncChannelLabel(connection: unknown) { * POST /api/providers/[id]/sync-models * * Fetches the model list from a provider's /models endpoint and replaces the - * full custom models list for that provider. Successful syncs only write a - * call log when the fetched channel actually changes the stored model list. + * full custom models list for that provider while refreshing the per-connection + * discovery cache. Successful syncs only write a call log when the fetched + * channel actually changes the stored model list. * * Used by: * - modelSyncScheduler (auto-sync on interval) @@ -155,9 +157,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) ? incomingUrl.origin : `http://127.0.0.1:${process.env.PORT || "20128"}`; - const modelsPath = `/api/providers/${encodeURIComponent(id)}/models`; + const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`; const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, { method: "GET", + cache: "no-store", headers: { cookie: request.headers.get("cookie") || "", ...buildModelSyncInternalHeaders(), @@ -212,25 +215,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const previousModels = await getCustomModels(logProvider); const replaced = await replaceCustomModels(logProvider, models); - // For Gemini: also write to syncedAvailableModels (unioned across API keys) - if (logProvider === "gemini") { - try { - const syncedModels = models.map((m: any) => ({ - id: m.id, - name: m.name || m.id, - source: "api-sync" as const, - ...(m.supportedEndpoints ? { supportedEndpoints: m.supportedEndpoints } : {}), - ...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}), - ...(typeof m.outputTokenLimit === "number" - ? { outputTokenLimit: m.outputTokenLimit } - : {}), - ...(typeof m.description === "string" ? { description: m.description } : {}), - ...(m.supportsThinking === true ? { supportsThinking: true } : {}), - })); + try { + const syncedModels = normalizeDiscoveredModels(fetchedModels); + if (syncedModels.length > 0) { await replaceSyncedAvailableModelsForConnection(logProvider, id, syncedModels); - } catch (e) { - console.error("Failed to union synced available models for gemini:", e); } + } catch (e) { + console.error(`Failed to union synced available models for ${logProvider}:`, e); } const modelChanges = summarizeModelChanges(previousModels, replaced); diff --git a/src/app/api/tunnels/tailscale/check/route.ts b/src/app/api/tunnels/tailscale/check/route.ts new file mode 100644 index 0000000000..864c396be4 --- /dev/null +++ b/src/app/api/tunnels/tailscale/check/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { getTailscaleCheckStatus } from "@/lib/tailscaleTunnel"; +import { requireTailscaleAuth } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + try { + const status = await getTailscaleCheckStatus(); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to check Tailscale state", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tunnels/tailscale/disable/route.ts b/src/app/api/tunnels/tailscale/disable/route.ts new file mode 100644 index 0000000000..858e337037 --- /dev/null +++ b/src/app/api/tunnels/tailscale/disable/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { disableTailscaleTunnel } from "@/lib/tailscaleTunnel"; +import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + const parsed = await parseOptionalJsonBody(request, tailscaleSudoSchema); + if ("response" in parsed) return parsed.response; + + try { + const result = await disableTailscaleTunnel(parsed.data); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to disable Tailscale Funnel", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tunnels/tailscale/enable/route.ts b/src/app/api/tunnels/tailscale/enable/route.ts new file mode 100644 index 0000000000..7918814de2 --- /dev/null +++ b/src/app/api/tunnels/tailscale/enable/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { enableTailscaleTunnel } from "@/lib/tailscaleTunnel"; +import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleEnableSchema } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + const parsed = await parseOptionalJsonBody(request, tailscaleEnableSchema); + if ("response" in parsed) return parsed.response; + + try { + const result = await enableTailscaleTunnel(parsed.data); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to enable Tailscale Funnel", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tunnels/tailscale/install/route.ts b/src/app/api/tunnels/tailscale/install/route.ts new file mode 100644 index 0000000000..b5f747eeb2 --- /dev/null +++ b/src/app/api/tunnels/tailscale/install/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { getTailscaleTunnelStatus, installTailscale } from "@/lib/tailscaleTunnel"; +import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + const parsed = await parseOptionalJsonBody(request, tailscaleSudoSchema); + if ("response" in parsed) return parsed.response; + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const pushEvent = (event: string, payload: Record) => { + controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`)); + }; + + void (async () => { + try { + await installTailscale({ + sudoPassword: parsed.data.sudoPassword, + onProgress: (message) => pushEvent("progress", { message }), + }); + + pushEvent("done", { + success: true, + status: await getTailscaleTunnelStatus(), + }); + } catch (error) { + pushEvent("error", { + error: error instanceof Error ? error.message : "Failed to install Tailscale", + }); + } finally { + controller.close(); + } + })(); + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/src/app/api/tunnels/tailscale/login/route.ts b/src/app/api/tunnels/tailscale/login/route.ts new file mode 100644 index 0000000000..2606430723 --- /dev/null +++ b/src/app/api/tunnels/tailscale/login/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import { startTailscaleLogin } from "@/lib/tailscaleTunnel"; +import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleLoginSchema } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + const parsed = await parseOptionalJsonBody(request, tailscaleLoginSchema); + if ("response" in parsed) return parsed.response; + + try { + const result = await startTailscaleLogin(parsed.data); + return NextResponse.json(result); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to start Tailscale login", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tunnels/tailscale/route.ts b/src/app/api/tunnels/tailscale/route.ts new file mode 100644 index 0000000000..2adf374ae8 --- /dev/null +++ b/src/app/api/tunnels/tailscale/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; +import { getTailscaleTunnelStatus } from "@/lib/tailscaleTunnel"; +import { requireTailscaleAuth } from "./routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + try { + const status = await getTailscaleTunnelStatus(); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to load Tailscale status", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tunnels/tailscale/routeUtils.ts b/src/app/api/tunnels/tailscale/routeUtils.ts new file mode 100644 index 0000000000..131005bcbb --- /dev/null +++ b/src/app/api/tunnels/tailscale/routeUtils.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; + +export const tailscaleEnableSchema = z.object({ + sudoPassword: z.string().optional(), + hostname: z.string().optional(), + port: z.number().int().positive().optional(), +}); + +export const tailscaleLoginSchema = z.object({ + hostname: z.string().optional(), +}); + +export const tailscaleSudoSchema = z.object({ + sudoPassword: z.string().optional(), +}); + +export async function requireTailscaleAuth(request: Request) { + if (await isAuthenticated(request)) return null; + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function parseOptionalJsonBody(request: Request, schema: T) { + let rawBody: unknown = {}; + + try { + const rawText = await request.text(); + rawBody = rawText.trim() ? JSON.parse(rawText) : {}; + } catch { + return { response: NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }) }; + } + + const validation = validateBody(schema, rawBody); + if (isValidationFailure(validation)) { + return { response: validation.response }; + } + + return { data: validation.data }; +} diff --git a/src/app/api/tunnels/tailscale/start-daemon/route.ts b/src/app/api/tunnels/tailscale/start-daemon/route.ts new file mode 100644 index 0000000000..b3b1208834 --- /dev/null +++ b/src/app/api/tunnels/tailscale/start-daemon/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { getTailscaleTunnelStatus, startTailscaleDaemon } from "@/lib/tailscaleTunnel"; +import { parseOptionalJsonBody, requireTailscaleAuth, tailscaleSudoSchema } from "../routeUtils"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + const authError = await requireTailscaleAuth(request); + if (authError) return authError; + + const parsed = await parseOptionalJsonBody(request, tailscaleSudoSchema); + if ("response" in parsed) return parsed.response; + + try { + await startTailscaleDaemon(parsed.data); + return NextResponse.json({ + success: true, + status: await getTailscaleTunnelStatus(), + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to start the Tailscale daemon", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 872ddc8723..36228393a4 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -18,7 +18,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { getSyncedAvailableModels } from "@/lib/db/models"; +import { getAllSyncedAvailableModels } from "@/lib/db/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; import { @@ -346,16 +346,33 @@ export async function getUnifiedModelsResponse( } } - // Gemini: synced API models exclusively (outside PROVIDER_MODELS loop since registry is empty) - if (activeAliases.has("gemini") && !blockedProviders.has("gemini")) { - try { - const syncedModels = await getSyncedAvailableModels("gemini"); + try { + const syncedModelsByProvider = await getAllSyncedAvailableModels(); + for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) { + if (!Array.isArray(syncedModels) || syncedModels.length === 0) continue; + if (blockedProviders.has(providerId)) continue; + + const prefix = providerIdToPrefix[providerId]; + const alias = prefix || providerIdToAlias[providerId] || providerId; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || providerId; + const parentProviderType = nodeIdToProviderType[providerId]; + + if ( + !activeAliases.has(alias) && + !activeAliases.has(canonicalProviderId) && + !activeAliases.has(providerId) && + !(parentProviderType && activeAliases.has(parentProviderType)) + ) { + continue; + } + for (const sm of syncedModels) { - if (!providerSupportsModel("gemini", sm.id)) continue; - const aliasId = `gemini/${sm.id}`; - if (getModelIsHidden("gemini", sm.id)) continue; + if (!providerSupportsModel(canonicalProviderId, sm.id)) continue; + if (getModelIsHidden(providerId, sm.id)) continue; + + const aliasId = `${alias}/${sm.id}`; + if (models.some((model) => model.id === aliasId)) continue; - // Convert supportedEndpoints to type/subtype for endpoint categorization const endpoints = Array.isArray(sm.supportedEndpoints) ? sm.supportedEndpoints : ["chat"]; let modelType: string | undefined; if (endpoints.includes("embeddings")) modelType = "embedding"; @@ -366,7 +383,7 @@ export async function getUnifiedModelsResponse( id: aliasId, object: "model", created: timestamp, - owned_by: "gemini", + owned_by: canonicalProviderId, permission: [], root: sm.id, parent: null, @@ -378,13 +395,12 @@ export async function getUnifiedModelsResponse( : {}), }); - // For audio models, also add a speech variant so they appear in both sections if (modelType === "audio") { models.push({ id: aliasId, object: "model", created: timestamp, - owned_by: "gemini", + owned_by: canonicalProviderId, permission: [], root: sm.id, parent: null, @@ -396,10 +412,30 @@ export async function getUnifiedModelsResponse( : {}), }); } + + if (canonicalProviderId !== alias && !prefix) { + const providerPrefixedId = `${canonicalProviderId}/${sm.id}`; + if (!models.some((model) => model.id === providerPrefixedId)) { + models.push({ + id: providerPrefixedId, + object: "model", + created: timestamp, + owned_by: canonicalProviderId, + permission: [], + root: sm.id, + parent: aliasId, + ...(modelType ? { type: modelType } : {}), + ...(sm.inputTokenLimit ? { context_length: sm.inputTokenLimit } : {}), + ...(endpoints.length > 1 || !endpoints.includes("chat") + ? { supported_endpoints: endpoints } + : {}), + }); + } + } } - } catch (err) { - console.error("[catalog] Error fetching synced Gemini models:", err); } + } catch (err) { + console.error("[catalog] Error fetching synced provider models:", err); } // Helper: check if a provider is active (by provider id or alias) diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 89382ef98f..4f7693c4d4 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -528,6 +528,28 @@ export interface SyncedAvailableModel { supportsThinking?: boolean; } +/** + * Get synced available models for a specific provider connection. + */ +export async function getSyncedAvailableModelsForConnection( + providerId: string, + connectionId: string +): Promise { + const db = getDbInstance(); + const key = `${providerId}:${connectionId}`; + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?") + .get(key); + const value = getKeyValue(row).value; + if (!value) return []; + try { + const models = JSON.parse(value); + return Array.isArray(models) ? models : []; + } catch { + return []; + } +} + /** * Get all synced available models for a provider, unioned across all connections. */ diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index d6c43f2cc8..8522b9e171 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -44,6 +44,8 @@ export async function getSettings() { const rows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'settings'").all(); const settings: Record = { cloudEnabled: false, + tailscaleEnabled: false, + tailscaleUrl: "", stickyRoundRobinLimit: 3, requestRetry: 3, maxRetryIntervalSec: 30, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts new file mode 100644 index 0000000000..1e50f729eb --- /dev/null +++ b/src/lib/providerModels/modelDiscovery.ts @@ -0,0 +1,83 @@ +import { + getSyncedAvailableModelsForConnection, + replaceSyncedAvailableModelsForConnection, + type SyncedAvailableModel, +} from "@/lib/db/models"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean { + return asRecord(providerSpecificData).autoFetchModels !== false; +} + +export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel[] { + const items = Array.isArray(models) ? models : []; + const deduped = new Map(); + + for (const item of items) { + const record = asRecord(item); + const id = + toNonEmptyString(record.id) || + toNonEmptyString(record.name) || + toNonEmptyString(record.model); + if (!id) continue; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ) + : undefined; + + deduped.set(id, { + id, + name, + source: "api-sync", + ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), + ...(typeof record.inputTokenLimit === "number" + ? { inputTokenLimit: record.inputTokenLimit } + : {}), + ...(typeof record.outputTokenLimit === "number" + ? { outputTokenLimit: record.outputTokenLimit } + : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(record.supportsThinking === true ? { supportsThinking: true } : {}), + }); + } + + return Array.from(deduped.values()); +} + +export async function getCachedDiscoveredModels( + providerId: string, + connectionId: string +): Promise { + return getSyncedAvailableModelsForConnection(providerId, connectionId); +} + +export async function persistDiscoveredModels( + providerId: string, + connectionId: string, + models: unknown +): Promise { + const normalized = normalizeDiscoveredModels(models); + if (normalized.length === 0) return []; + await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized); + return normalized; +} diff --git a/src/lib/providers/claudeExtraUsage.ts b/src/lib/providers/claudeExtraUsage.ts new file mode 100644 index 0000000000..b632eaa57d --- /dev/null +++ b/src/lib/providers/claudeExtraUsage.ts @@ -0,0 +1,160 @@ +type JsonRecord = Record; + +type ClaudeExtraUsageConnectionState = { + provider?: string | null; + providerSpecificData?: unknown; + testStatus?: string | null; + lastError?: string | null; + lastErrorAt?: string | null; + lastErrorType?: string | null; + lastErrorSource?: string | null; + errorCode?: string | number | null; + rateLimitedUntil?: string | null; + backoffLevel?: number | null; +}; + +type ClaudeExtraUsageUpdate = { + testStatus: string | null; + lastError: string | null; + lastErrorAt: string | null; + lastErrorType: string | null; + lastErrorSource: string | null; + errorCode: number | null; + rateLimitedUntil: string | null; + backoffLevel: number; +}; + +const EXTRA_USAGE_FALLBACK_BLOCK_MS = 5 * 60 * 1000; +const SESSION_RESET_WINDOW_KEYS = ["session (5h)", "session", "five_hour"]; + +export const CLAUDE_EXTRA_USAGE_ERROR_SOURCE = "extra_usage"; +export const CLAUDE_EXTRA_USAGE_ERROR_MESSAGE = + "Claude extra usage was detected and blocked by this connection policy."; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toStringOrNull(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function toFutureIso(value: unknown): string | null { + const raw = toStringOrNull(value); + if (!raw) return null; + const ms = new Date(raw).getTime(); + if (!Number.isFinite(ms) || ms <= Date.now()) return null; + return raw; +} + +export function isClaudeExtraUsageBlockEnabled( + provider: string | null | undefined, + providerSpecificData: unknown +): boolean { + if (provider !== "claude") return false; + return asRecord(providerSpecificData).blockExtraUsage !== false; +} + +export function isClaudeExtraUsageQueued(usage: unknown): boolean { + return asRecord(asRecord(usage).extraUsage).queued === true; +} + +export function isClaudeExtraUsageState(connection: ClaudeExtraUsageConnectionState): boolean { + return toStringOrNull(connection.lastErrorSource) === CLAUDE_EXTRA_USAGE_ERROR_SOURCE; +} + +export function resolveClaudeExtraUsageResetAt(usage: unknown): string | null { + const quotas = asRecord(asRecord(usage).quotas); + + for (const key of SESSION_RESET_WINDOW_KEYS) { + const resetAt = toFutureIso(asRecord(quotas[key]).resetAt); + if (resetAt) return resetAt; + } + + let earliest: string | null = null; + let earliestMs = Number.POSITIVE_INFINITY; + + for (const quota of Object.values(quotas)) { + const resetAt = toFutureIso(asRecord(quota).resetAt); + if (!resetAt) continue; + const ms = new Date(resetAt).getTime(); + if (ms < earliestMs) { + earliest = resetAt; + earliestMs = ms; + } + } + + return earliest; +} + +function hasClaudeUsageSnapshot(usage: unknown): boolean { + const record = asRecord(usage); + return Object.prototype.hasOwnProperty.call(record, "extraUsage") || !!record.quotas; +} + +function buildClearUpdate(): ClaudeExtraUsageUpdate { + return { + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + backoffLevel: 0, + }; +} + +export function buildClaudeExtraUsageStateClearUpdate( + connection: ClaudeExtraUsageConnectionState +): ClaudeExtraUsageUpdate | null { + return isClaudeExtraUsageState(connection) ? buildClearUpdate() : null; +} + +export function buildClaudeExtraUsageConnectionUpdate( + connection: ClaudeExtraUsageConnectionState, + usage: unknown +): ClaudeExtraUsageUpdate | null { + if (connection.provider !== "claude" || !hasClaudeUsageSnapshot(usage)) { + return null; + } + + const snapshot = asRecord(usage); + const blockingEnabled = isClaudeExtraUsageBlockEnabled( + connection.provider, + connection.providerSpecificData + ); + const currentIsExtraUsageState = isClaudeExtraUsageState(connection); + + if (!blockingEnabled) { + return buildClaudeExtraUsageStateClearUpdate(connection); + } + + if (!isClaudeExtraUsageQueued(snapshot)) { + return buildClaudeExtraUsageStateClearUpdate(connection); + } + + const rateLimitedUntil = + resolveClaudeExtraUsageResetAt(snapshot) || + new Date(Date.now() + EXTRA_USAGE_FALLBACK_BLOCK_MS).toISOString(); + + if ( + currentIsExtraUsageState && + toStringOrNull(connection.rateLimitedUntil) === rateLimitedUntil && + toStringOrNull(connection.testStatus) === "unavailable" && + toStringOrNull(connection.lastErrorType) === "quota_exhausted" + ) { + return null; + } + + return { + testStatus: "unavailable", + lastError: CLAUDE_EXTRA_USAGE_ERROR_MESSAGE, + lastErrorAt: new Date().toISOString(), + lastErrorType: "quota_exhausted", + lastErrorSource: CLAUDE_EXTRA_USAGE_ERROR_SOURCE, + errorCode: 429, + rateLimitedUntil, + backoffLevel: Math.max(1, Number(connection.backoffLevel) || 0), + }; +} diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 6163626ec9..18e1e026c0 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -108,6 +108,14 @@ export function normalizeProviderSpecificData( delete normalized.openaiStoreEnabled; } + if ("blockExtraUsage" in normalized && typeof normalized.blockExtraUsage !== "boolean") { + delete normalized.blockExtraUsage; + } + + if ("autoFetchModels" in normalized && typeof normalized.autoFetchModels !== "boolean") { + delete normalized.autoFetchModels; + } + if ("tag" in normalized) { if (typeof normalized.tag === "string") { const trimmedTag = normalized.tag.trim(); diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index d1b1d308dd..a601781b4a 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -21,6 +21,7 @@ import { safeOutboundFetch, } from "@/shared/network/safeOutboundFetch"; import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { normalizeSessionCookieHeader } from "@/lib/providers/webCookieAuth"; import { getGigachatAccessToken } from "@omniroute/open-sse/services/gigachatAuth.ts"; import { validateQoderCliPat } from "@omniroute/open-sse/services/qoderCli.ts"; @@ -1187,6 +1188,114 @@ const SEARCH_VALIDATOR_CONFIGS: Record< }, }; +const META_AI_SEND_MESSAGE_DOC_ID = "078dfdff6fb0d420d8011b49073e6886"; +const META_AI_USER_AGENT = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"; +const META_AI_BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +function encodeMetaAiBase62(value: bigint, padLength: number): string { + let remaining = value; + let encoded = ""; + + while (remaining > 0n) { + encoded = META_AI_BASE62_ALPHABET[Number(remaining % 62n)] + encoded; + remaining /= 62n; + } + + return encoded.padStart(padLength, "0"); +} + +function decodeMetaAiBase62(value: string): bigint { + let decoded = 0n; + for (const char of value) { + const index = META_AI_BASE62_ALPHABET.indexOf(char); + if (index < 0) { + throw new Error(`Invalid Meta AI base62 character: ${char}`); + } + decoded = decoded * 62n + BigInt(index); + } + return decoded; +} + +function randomMetaAiBigInt(byteLength: number): bigint { + const bytes = new Uint8Array(byteLength); + crypto.getRandomValues(bytes); + let result = 0n; + for (const byte of bytes) { + result = (result << 8n) | BigInt(byte); + } + return result; +} + +function generateMetaAiConversationId(): string { + const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n); + const random = randomMetaAiBigInt(8) & ((1n << 64n) - 1n); + return `c.${encodeMetaAiBase62((timestamp << 64n) | random, 19)}`; +} + +function generateMetaAiEventId(conversationId: string): string | null { + if (!conversationId.startsWith("c.")) { + return null; + } + + try { + const packedConversation = decodeMetaAiBase62(conversationId.slice(2)); + const conversationRandom = packedConversation & ((1n << 64n) - 1n); + const timestamp = BigInt(Date.now()) & ((1n << 44n) - 1n); + const eventRandom = randomMetaAiBigInt(4) & ((1n << 32n) - 1n); + return `e.${encodeMetaAiBase62((timestamp << (64n + 32n)) | (conversationRandom << 32n) | eventRandom, 25)}`; + } catch { + return null; + } +} + +function generateMetaAiNumericMessageId(): string { + return ( + BigInt(Date.now()) * 1000n + + BigInt(Math.floor(Math.random() * 1000)) + + (randomMetaAiBigInt(2) & 0xfffn) + ).toString(); +} + +function buildMetaAiValidationBody() { + const conversationId = generateMetaAiConversationId(); + return { + doc_id: META_AI_SEND_MESSAGE_DOC_ID, + variables: { + assistantMessageId: crypto.randomUUID(), + attachments: null, + clientLatitude: null, + clientLongitude: null, + clientTimezone: + typeof Intl !== "undefined" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC", + clippyIp: null, + content: "test", + conversationId, + conversationStarterId: null, + currentBranchPath: "0", + developerOverridesForMessage: null, + devicePixelRatio: 1, + entryPoint: "KADABRA__CHAT__UNIFIED_INPUT_BAR", + imagineOperationRequest: null, + isNewConversation: true, + mentions: null, + mode: "mode_fast", + promptEditType: null, + promptSessionId: crypto.randomUUID(), + promptType: null, + qplJoinId: null, + requestedToolCall: null, + rewriteOptions: null, + turnId: crypto.randomUUID(), + userAgent: META_AI_USER_AGENT, + userEventId: generateMetaAiEventId(conversationId), + userLocale: "en_US", + userMessageId: crypto.randomUUID(), + userUniqueMessageId: generateMetaAiNumericMessageId(), + }, + }; +} + async function validateGrokWebProvider({ apiKey, providerSpecificData = {} }: any) { try { let token = apiKey; @@ -1359,6 +1468,173 @@ async function validatePerplexityWebProvider({ apiKey, providerSpecificData = {} } } +async function validateBlackboxWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const cookieHeader = normalizeSessionCookieHeader(apiKey, "__Secure-authjs.session-token"); + const sessionHeaders = applyCustomUserAgent( + { + Accept: "application/json", + Cookie: cookieHeader, + Origin: "https://app.blackbox.ai", + Referer: "https://app.blackbox.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + }, + providerSpecificData + ); + + const sessionResponse = await validationRead("https://app.blackbox.ai/api/auth/session", { + method: "GET", + headers: sessionHeaders, + }); + + const sessionText = await sessionResponse.text(); + const sessionPayload = sessionText ? JSON.parse(sessionText) : null; + const userEmail = sessionPayload?.user?.email; + + if (!sessionResponse.ok || !userEmail) { + return { + valid: false, + error: + "Invalid Blackbox session cookie — re-paste __Secure-authjs.session-token from app.blackbox.ai", + }; + } + + const subscriptionHeaders = applyCustomUserAgent( + { + "Content-Type": "application/json", + Accept: "application/json", + Cookie: cookieHeader, + Origin: "https://app.blackbox.ai", + Referer: "https://app.blackbox.ai/", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + }, + providerSpecificData + ); + + const subscriptionResponse = await validationWrite( + "https://app.blackbox.ai/api/check-subscription", + { + method: "POST", + headers: subscriptionHeaders, + body: JSON.stringify({ email: userEmail }), + } + ); + + const subscriptionText = await subscriptionResponse.text(); + const subscriptionPayload = subscriptionText ? JSON.parse(subscriptionText) : null; + const explicitActive = + subscriptionPayload?.hasActiveSubscription === true || + subscriptionPayload?.isTrialSubscription === true || + subscriptionPayload?.status === "PREMIUM"; + const explicitInactive = + subscriptionPayload?.hasActiveSubscription === false || + subscriptionPayload?.status === "FREE"; + const requiresAuthentication = + subscriptionPayload?.requiresAuthentication === true || + /login is required/i.test(subscriptionText || ""); + + if (subscriptionResponse.status === 401 || subscriptionResponse.status === 403) { + return { + valid: false, + error: + "Invalid Blackbox session cookie — re-paste __Secure-authjs.session-token from app.blackbox.ai", + }; + } + + if (requiresAuthentication) { + return { + valid: false, + error: + "Blackbox session expired — re-paste __Secure-authjs.session-token from app.blackbox.ai", + }; + } + + if (subscriptionResponse.ok && explicitActive) { + return { valid: true, error: null }; + } + + if ( + (subscriptionResponse.ok && explicitInactive) || + subscriptionPayload?.previouslySubscribed + ) { + return { + valid: false, + error: + "Blackbox account authenticated, but no active paid subscription was detected for premium web models.", + }; + } + + if (subscriptionResponse.ok) { + return { valid: true, error: null }; + } + + if (subscriptionResponse.status >= 500) { + return { valid: false, error: `Blackbox unavailable (${subscriptionResponse.status})` }; + } + + return { valid: false, error: `Validation failed: ${subscriptionResponse.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + +async function validateMuseSparkWebProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const cookieHeader = normalizeSessionCookieHeader(apiKey, "abra_sess"); + const response = await validationWrite("https://www.meta.ai/api/graphql", { + method: "POST", + headers: applyCustomUserAgent( + { + "Content-Type": "application/json", + Accept: "text/event-stream", + Cookie: cookieHeader, + Origin: "https://www.meta.ai", + Referer: "https://www.meta.ai/", + "User-Agent": META_AI_USER_AGENT, + }, + providerSpecificData + ), + body: JSON.stringify(buildMetaAiValidationBody()), + }); + + const responseText = await response.text(); + if (response.status === 401 || response.status === 403) { + return { + valid: false, + error: "Invalid Meta AI session cookie — re-paste abra_sess from meta.ai", + }; + } + + if (/authentication required to send messages|login is required|sign in/i.test(responseText)) { + return { + valid: false, + error: "Invalid Meta AI session cookie — re-paste abra_sess from meta.ai", + }; + } + + if ( + response.status === 429 || + /limit exceeded|rate limit|too many requests/i.test(responseText) + ) { + return { valid: true, error: null }; + } + + if (response.ok) { + return { valid: true, error: null }; + } + + if (response.status >= 500) { + return { valid: false, error: `Meta AI unavailable (${response.status})` }; + } + + return { valid: false, error: `Validation failed: ${response.status}` }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = provider !== "searxng-search"; if (!provider || (requiresApiKey && !apiKey)) { @@ -1400,6 +1676,8 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi gigachat: validateGigachatProvider, "grok-web": validateGrokWebProvider, "perplexity-web": validatePerplexityWebProvider, + "blackbox-web": validateBlackboxWebProvider, + "muse-spark-web": validateMuseSparkWebProvider, "azure-openai": validateAzureOpenAIProvider, vertex: async ({ apiKey }: any) => { try { diff --git a/src/lib/providers/webCookieAuth.ts b/src/lib/providers/webCookieAuth.ts new file mode 100644 index 0000000000..3425ff8cf7 --- /dev/null +++ b/src/lib/providers/webCookieAuth.ts @@ -0,0 +1,18 @@ +export function stripCookieInputPrefix(rawValue: string): string { + const trimmed = (rawValue || "").trim(); + if (!trimmed) return ""; + + const withoutBearer = trimmed.replace(/^bearer\s+/i, ""); + return withoutBearer.replace(/^cookie:/i, "").trim(); +} + +export function normalizeSessionCookieHeader(rawValue: string, defaultCookieName: string): string { + const normalized = stripCookieInputPrefix(rawValue); + if (!normalized) return ""; + + if (normalized.includes("=")) { + return normalized; + } + + return `${defaultCookieName}=${normalized}`; +} diff --git a/src/lib/tailscaleTunnel.ts b/src/lib/tailscaleTunnel.ts new file mode 100644 index 0000000000..0786d16732 --- /dev/null +++ b/src/lib/tailscaleTunnel.ts @@ -0,0 +1,1105 @@ +import { execFile, spawn } from "child_process"; +import fs from "fs"; +import fsPromises from "fs/promises"; +import os from "os"; +import path from "path"; +import { promisify } from "util"; +import { getSettings, updateSettings } from "@/lib/db/settings"; +import { resolveDataDir } from "@/lib/dataPaths"; +import { getRuntimePorts } from "@/lib/runtime/ports"; +import { execWithPassword } from "@/mitm/dns/dnsConfig"; +import { getCachedPassword, setCachedPassword } from "@/mitm/manager"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +const execFileAsync = promisify(execFile); + +const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; +const WINDOWS_TAILSCALED_BIN = "C:\\Program Files\\Tailscale\\tailscaled.exe"; +const IS_MAC = process.platform === "darwin"; +const IS_LINUX = process.platform === "linux"; +const IS_WINDOWS = process.platform === "win32"; +const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; +const LOGIN_TIMEOUT_MS = 15000; +const FUNNEL_TIMEOUT_MS = 30000; + +type JsonRecord = Record; + +export type TailscaleTunnelInstallSource = "managed" | "path" | "env" | "windows-default"; +export type TailscaleTunnelPhase = + | "unsupported" + | "not_installed" + | "needs_login" + | "stopped" + | "running" + | "error"; + +type PersistedTailscaleState = { + binaryPath?: string | null; + installSource?: TailscaleTunnelInstallSource | null; + daemonPid?: number | null; + tunnelUrl?: string | null; + lastError?: string | null; + installedAt?: string | null; + updatedAt?: string | null; +}; + +type BinaryResolution = { + binaryPath: string | null; + installSource: TailscaleTunnelInstallSource | null; + managedInstall: boolean; +}; + +type TailscaleLoginResult = { alreadyLoggedIn: true } | { authUrl: string }; + +type TailscaleFunnelResult = + | { tunnelUrl: string } + | { funnelNotEnabled: true; enableUrl: string | null }; + +export type TailscaleCheckStatus = { + supported: boolean; + installed: boolean; + managedInstall: boolean; + installSource: TailscaleTunnelInstallSource | null; + binaryPath: string | null; + loggedIn: boolean; + daemonRunning: boolean; + running: boolean; + tunnelUrl: string | null; + apiUrl: string | null; + platform: NodeJS.Platform; + brewAvailable: boolean; + lastError: string | null; + pid: number | null; +}; + +export type TailscaleTunnelStatus = TailscaleCheckStatus & { + enabled: boolean; + phase: TailscaleTunnelPhase; +}; + +export type TailscaleEnableResult = + | { + success: true; + tunnelUrl: string; + apiUrl: string | null; + status: TailscaleTunnelStatus; + } + | { + success: false; + needsLogin: true; + authUrl: string; + status: TailscaleTunnelStatus; + } + | { + success: false; + funnelNotEnabled: true; + enableUrl: string | null; + status: TailscaleTunnelStatus; + }; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function toNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function shellEscape(value: string) { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function isSupportedPlatform(platform = process.platform) { + return platform === "darwin" || platform === "linux" || platform === "win32"; +} + +function getTailscaleDir() { + return path.join(resolveDataDir(), "tailscale"); +} + +function getManagedBinaryPath(platform = process.platform) { + return path.join(getTailscaleDir(), "bin", platform === "win32" ? "tailscale.exe" : "tailscale"); +} + +function getStateFilePath() { + return path.join(getTailscaleDir(), "state.json"); +} + +function getPidFilePath() { + return path.join(getTailscaleDir(), ".tailscaled.pid"); +} + +function getLogFilePath() { + return path.join(getTailscaleDir(), "tailscaled.log"); +} + +export function getTailscaleSocketPath() { + return path.join(getTailscaleDir(), "tailscaled.sock"); +} + +async function ensureTailscaleDir() { + await fsPromises.mkdir(path.join(getTailscaleDir(), "bin"), { recursive: true }); +} + +async function readStateFile(): Promise { + try { + const raw = await fsPromises.readFile(getStateFilePath(), "utf8"); + return JSON.parse(raw) as PersistedTailscaleState; + } catch { + return {}; + } +} + +async function writeStateFile(state: PersistedTailscaleState) { + await ensureTailscaleDir(); + await fsPromises.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8"); +} + +async function updateStateFile(patch: Partial) { + const current = await readStateFile(); + await writeStateFile({ + ...current, + ...patch, + updatedAt: new Date().toISOString(), + }); +} + +async function readPidFile() { + try { + const raw = await fsPromises.readFile(getPidFilePath(), "utf8"); + const pid = Number.parseInt(raw.trim(), 10); + return Number.isFinite(pid) ? pid : null; + } catch { + return null; + } +} + +async function clearPidFile() { + try { + await fsPromises.unlink(getPidFilePath()); + } catch { + // Ignore stale or missing pid files. + } +} + +function isProcessAlive(pid: number | null) { + if (!pid || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function getTailscaleApiUrl(tunnelUrl: string | null) { + return tunnelUrl ? `${tunnelUrl.replace(/\/$/, "")}/v1` : null; +} + +async function resolvePathCommand(command: string) { + const lookupCommand = process.platform === "win32" ? "where" : "which"; + try { + const { stdout } = await execFileAsync(lookupCommand, [command], { + timeout: 3000, + windowsHide: true, + env: { + ...process.env, + PATH: EXTENDED_PATH, + }, + }); + const first = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return first || null; + } catch { + return null; + } +} + +async function resolveBinary(): Promise { + const envPath = toNonEmptyString(process.env.TAILSCALE_BIN); + if (envPath && fs.existsSync(envPath)) { + return { binaryPath: envPath, installSource: "env", managedInstall: false }; + } + + const managedPath = getManagedBinaryPath(); + if (fs.existsSync(managedPath)) { + return { binaryPath: managedPath, installSource: "managed", managedInstall: true }; + } + + const pathBinary = await resolvePathCommand("tailscale"); + if (pathBinary) { + return { binaryPath: pathBinary, installSource: "path", managedInstall: false }; + } + + if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { + return { + binaryPath: WINDOWS_TAILSCALE_BIN, + installSource: "windows-default", + managedInstall: false, + }; + } + + return { binaryPath: null, installSource: null, managedInstall: false }; +} + +async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { + const envPath = toNonEmptyString(process.env.TAILSCALED_BIN); + if (envPath && fs.existsSync(envPath)) return envPath; + + const sibling = tailscaleBinaryPath + ? path.join( + path.dirname(tailscaleBinaryPath), + process.platform === "win32" ? "tailscaled.exe" : "tailscaled" + ) + : null; + if (sibling && fs.existsSync(sibling)) return sibling; + + const pathBinary = await resolvePathCommand("tailscaled"); + if (pathBinary) return pathBinary; + + if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALED_BIN)) return WINDOWS_TAILSCALED_BIN; + + return null; +} + +function buildExecEnv() { + return { + ...process.env, + PATH: EXTENDED_PATH, + }; +} + +function buildTailscaleArgs(...args: string[]) { + if (IS_WINDOWS) return args; + return ["--socket", getTailscaleSocketPath(), ...args]; +} + +async function readJsonCommand(binaryPath: string, args: string[], timeout = 5000) { + try { + const { stdout } = await execFileAsync(binaryPath, args, { + timeout, + windowsHide: true, + env: buildExecEnv(), + }); + return JSON.parse(stdout) as JsonRecord; + } catch { + return null; + } +} + +async function getLiveStatusPayload(binaryPath: string | null) { + if (!binaryPath) return null; + return readJsonCommand(binaryPath, buildTailscaleArgs("status", "--json")); +} + +async function getLiveFunnelPayload(binaryPath: string | null) { + if (!binaryPath) return null; + return readJsonCommand(binaryPath, buildTailscaleArgs("funnel", "status", "--json")); +} + +function isBackendRunning(payload: unknown) { + return toNonEmptyString(asRecord(payload).BackendState) === "Running"; +} + +function isFunnelRunning(payload: unknown) { + const allowFunnel = asRecord(payload).AllowFunnel; + return Boolean( + allowFunnel && typeof allowFunnel === "object" && Object.keys(allowFunnel).length > 0 + ); +} + +export function getTailscaleUrlFromStatusPayload(payload: unknown) { + const self = asRecord(asRecord(payload).Self); + const dnsName = toNonEmptyString(self.DNSName); + if (!dnsName) return null; + const normalized = dnsName.replace(/\.$/, ""); + return normalized ? `https://${normalized}` : null; +} + +export function extractTailscaleAuthUrl(text: string) { + const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9-]+/); + return match ? match[0] : null; +} + +export function extractTailscaleEnableUrl(text: string) { + const match = text.match(/https:\/\/login\.tailscale\.com\/[^\s"']+/); + return match ? match[0] : null; +} + +export function extractTailscaleFunnelUrl(text: string) { + const match = text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net\b[^\s"']*/i); + if (!match) return null; + return match[0].replace(/\/$/, ""); +} + +async function getDefaultHostname() { + try { + const machineId = await getConsistentMachineId(); + const normalized = `omniroute-${machineId.slice(0, 8)}`.replace(/[^a-zA-Z0-9-]/g, "-"); + return normalized.toLowerCase(); + } catch { + const hostname = os + .hostname() + .replace(/[^a-zA-Z0-9-]/g, "-") + .toLowerCase(); + return hostname || "omniroute"; + } +} + +function getLastError(state: PersistedTailscaleState) { + return typeof state.lastError === "string" && state.lastError.trim() ? state.lastError : null; +} + +async function hasBrew() { + if (!IS_MAC) return false; + try { + await execFileAsync("which", ["brew"], { + timeout: 3000, + windowsHide: true, + env: buildExecEnv(), + }); + return true; + } catch { + return false; + } +} + +async function getLiveTunnelUrl(binaryPath: string | null) { + const payload = await getLiveStatusPayload(binaryPath); + return getTailscaleUrlFromStatusPayload(payload); +} + +export async function getTailscaleCheckStatus(): Promise { + const resolution = await resolveBinary(); + const [state, statusPayload, funnelPayload, brewAvailable] = await Promise.all([ + readStateFile(), + getLiveStatusPayload(resolution.binaryPath), + getLiveFunnelPayload(resolution.binaryPath), + hasBrew(), + ]); + + const liveTunnelUrl = getTailscaleUrlFromStatusPayload(statusPayload); + const storedTunnelUrl = toNonEmptyString(state.tunnelUrl); + const tunnelUrl = liveTunnelUrl || storedTunnelUrl; + const loggedIn = isBackendRunning(statusPayload); + const daemonRunning = Boolean(statusPayload) || isProcessAlive((await readPidFile()) || null); + + return { + supported: isSupportedPlatform(), + installed: Boolean(resolution.binaryPath), + managedInstall: resolution.managedInstall, + installSource: resolution.installSource, + binaryPath: resolution.binaryPath, + loggedIn, + daemonRunning, + running: isFunnelRunning(funnelPayload), + tunnelUrl, + apiUrl: getTailscaleApiUrl(tunnelUrl), + platform: process.platform, + brewAvailable, + lastError: getLastError(state), + pid: await readPidFile(), + }; +} + +export async function getTailscaleTunnelStatus(): Promise { + const [check, settings] = await Promise.all([getTailscaleCheckStatus(), getSettings()]); + const storedSettingUrl = + typeof settings.tailscaleUrl === "string" && settings.tailscaleUrl.trim() + ? settings.tailscaleUrl + : null; + const tunnelUrl = check.tunnelUrl || storedSettingUrl; + const running = check.loggedIn && check.running && Boolean(tunnelUrl); + const enabled = settings.tailscaleEnabled === true && running; + + let phase: TailscaleTunnelPhase = "stopped"; + if (!check.supported) phase = "unsupported"; + else if (!check.installed) phase = "not_installed"; + else if (running) phase = "running"; + else if (check.daemonRunning && !check.loggedIn) phase = "needs_login"; + else if (check.lastError) phase = "error"; + + return { + ...check, + running, + enabled, + tunnelUrl, + apiUrl: getTailscaleApiUrl(tunnelUrl), + phase, + }; +} + +async function runSudoShell(command: string, password: string) { + const normalizedPassword = password.trim(); + if (!normalizedPassword) { + throw new Error("Sudo password required"); + } + await execWithPassword(`sudo -S sh -c ${shellEscape(command)}`, normalizedPassword); +} + +export async function startTailscaleDaemon({ + sudoPassword, +}: { + sudoPassword?: string; +} = {}) { + const resolution = await resolveBinary(); + if (!resolution.binaryPath) { + throw new Error("Tailscale is not installed"); + } + + const existingStatus = await getLiveStatusPayload(resolution.binaryPath); + if (existingStatus) { + return { started: false }; + } + + if (IS_WINDOWS) { + try { + await execFileAsync("net", ["start", "Tailscale"], { + timeout: 10000, + windowsHide: true, + env: buildExecEnv(), + }); + } catch { + // Ignore service start errors and verify below. + } + + await sleep(2500); + if (!(await getLiveStatusPayload(resolution.binaryPath))) { + throw new Error("Failed to start Tailscale service"); + } + + return { started: true }; + } + + const daemonBinary = await resolveDaemonBinary(resolution.binaryPath); + if (!daemonBinary) { + throw new Error("tailscaled binary not found"); + } + + const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; + if (!password) { + throw new Error("Sudo password required to start tailscaled"); + } + + setCachedPassword(password); + await ensureTailscaleDir(); + + const command = [ + `mkdir -p ${shellEscape(getTailscaleDir())}`, + `nohup ${shellEscape(daemonBinary)} --socket=${shellEscape(getTailscaleSocketPath())} --statedir=${shellEscape(getTailscaleDir())} >> ${shellEscape(getLogFilePath())} 2>&1 & echo $! > ${shellEscape(getPidFilePath())}`, + ].join(" && "); + + await runSudoShell(command, password); + await sleep(3000); + + if (!(await getLiveStatusPayload(resolution.binaryPath))) { + throw new Error("tailscaled did not become ready"); + } + + const pid = await readPidFile(); + await updateStateFile({ + binaryPath: resolution.binaryPath, + installSource: resolution.installSource, + daemonPid: pid, + lastError: null, + }); + + return { started: true }; +} + +export async function startTailscaleLogin({ + hostname, +}: { + hostname?: string; +} = {}): Promise { + const resolution = await resolveBinary(); + if (!resolution.binaryPath) { + throw new Error("Tailscale is not installed"); + } + + const currentStatus = await getLiveStatusPayload(resolution.binaryPath); + if (isBackendRunning(currentStatus)) { + return { alreadyLoggedIn: true }; + } + + const resolvedHostname = toNonEmptyString(hostname) || (await getDefaultHostname()); + + return new Promise((resolve, reject) => { + const child = spawn( + resolution.binaryPath as string, + buildTailscaleArgs( + "up", + "--accept-routes", + ...(resolvedHostname ? [`--hostname=${resolvedHostname}`] : []) + ), + { + detached: true, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + } + ); + + let settled = false; + let output = ""; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const handleData = (chunk: Buffer | string) => { + output += chunk.toString(); + const authUrl = extractTailscaleAuthUrl(output); + if (authUrl) { + settle(() => resolve({ authUrl })); + } + }; + + const timeoutId = setTimeout(() => { + const authUrl = extractTailscaleAuthUrl(output); + if (authUrl) { + settle(() => resolve({ authUrl })); + return; + } + settle(() => reject(new Error("Tailscale login timed out"))); + }, LOGIN_TIMEOUT_MS); + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + child.on("error", (error) => settle(() => reject(error))); + child.on("close", async (code) => { + if (settled) return; + + const authUrl = extractTailscaleAuthUrl(output); + if (authUrl) { + settle(() => resolve({ authUrl })); + return; + } + + const latestStatus = await getLiveStatusPayload(resolution.binaryPath); + if (code === 0 || isBackendRunning(latestStatus)) { + settle(() => resolve({ alreadyLoggedIn: true })); + return; + } + + settle(() => reject(new Error(`tailscale up exited with code ${code ?? "unknown"}`))); + }); + + child.unref(); + }); +} + +async function resetTailscaleFunnel(binaryPath: string) { + try { + await execFileAsync(binaryPath, buildTailscaleArgs("funnel", "--bg", "reset"), { + timeout: 5000, + windowsHide: true, + env: buildExecEnv(), + }); + } catch { + // Ignore stale or missing funnel state. + } +} + +export async function startTailscaleFunnel( + port = getRuntimePorts().apiPort +): Promise { + const resolution = await resolveBinary(); + if (!resolution.binaryPath) { + throw new Error("Tailscale is not installed"); + } + + await resetTailscaleFunnel(resolution.binaryPath); + + return new Promise((resolve, reject) => { + const child = spawn( + resolution.binaryPath as string, + buildTailscaleArgs("funnel", "--bg", String(port)), + { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + } + ); + + let settled = false; + let output = ""; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + callback(); + }; + + const finalizeFromOutput = async () => { + const url = + extractTailscaleFunnelUrl(output) || (await getLiveTunnelUrl(resolution.binaryPath)); + if (url) { + settle(() => resolve({ tunnelUrl: url })); + return; + } + + const enableUrl = extractTailscaleEnableUrl(output); + if (/funnel is not enabled/i.test(output) || enableUrl) { + settle(() => resolve({ funnelNotEnabled: true, enableUrl })); + return; + } + + settle(() => reject(new Error(output.trim() || "Failed to start Tailscale Funnel"))); + }; + + const handleData = (chunk: Buffer | string) => { + output += chunk.toString(); + + const tunnelUrl = extractTailscaleFunnelUrl(output); + if (tunnelUrl) { + settle(() => resolve({ tunnelUrl })); + return; + } + + const enableUrl = extractTailscaleEnableUrl(output); + if (/funnel is not enabled/i.test(output) && enableUrl) { + settle(() => resolve({ funnelNotEnabled: true, enableUrl })); + } + }; + + const timeoutId = setTimeout(() => { + void finalizeFromOutput(); + }, FUNNEL_TIMEOUT_MS); + + child.stdout.on("data", handleData); + child.stderr.on("data", handleData); + child.on("error", (error) => settle(() => reject(error))); + child.on("close", () => { + void finalizeFromOutput(); + }); + }); +} + +export async function stopTailscaleFunnel() { + const resolution = await resolveBinary(); + if (!resolution.binaryPath) return; + await resetTailscaleFunnel(resolution.binaryPath); +} + +export async function stopTailscaleDaemon({ + sudoPassword, +}: { + sudoPassword?: string; +} = {}) { + const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; + const pid = await readPidFile(); + + if (pid && isProcessAlive(pid)) { + try { + process.kill(pid, "SIGTERM"); + } catch { + // Ignore non-owned or stale processes. + } + } + + await sleep(1000); + + if (pid && isProcessAlive(pid) && password) { + try { + await runSudoShell(`kill ${Number(pid)}`, password); + } catch { + // Ignore fallback failures and keep trying generic process matches. + } + } + + if (!IS_WINDOWS) { + try { + await execFileAsync("pkill", ["-x", "tailscaled"], { + timeout: 3000, + windowsHide: true, + env: buildExecEnv(), + }); + } catch { + // Ignore when the daemon is not running or not owned by this user. + } + + if (password) { + try { + await runSudoShell("pkill -x tailscaled", password); + } catch { + // Ignore final privileged shutdown failures. + } + } + } else { + try { + await execFileAsync("net", ["stop", "Tailscale"], { + timeout: 10000, + windowsHide: true, + env: buildExecEnv(), + }); + } catch { + // Ignore service stop failures on Windows. + } + } + + await clearPidFile(); + try { + await fsPromises.unlink(getTailscaleSocketPath()); + } catch { + // Ignore missing sockets. + } +} + +export async function enableTailscaleTunnel({ + sudoPassword, + hostname, + port, +}: { + sudoPassword?: string; + hostname?: string; + port?: number; +} = {}): Promise { + const normalizedPassword = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; + if (normalizedPassword) { + setCachedPassword(normalizedPassword); + } + + const targetPort = port || getRuntimePorts().apiPort; + + try { + await startTailscaleDaemon({ sudoPassword: normalizedPassword }); + + const resolution = await resolveBinary(); + const currentStatus = await getLiveStatusPayload(resolution.binaryPath); + + if (!isBackendRunning(currentStatus)) { + const loginResult = await startTailscaleLogin({ hostname }); + if ("authUrl" in loginResult) { + await updateStateFile({ lastError: null }); + return { + success: false, + needsLogin: true, + authUrl: loginResult.authUrl, + status: await getTailscaleTunnelStatus(), + }; + } + } + + const funnelResult = await startTailscaleFunnel(targetPort); + if ("funnelNotEnabled" in funnelResult) { + await updateStateFile({ lastError: null }); + return { + success: false, + funnelNotEnabled: true, + enableUrl: funnelResult.enableUrl, + status: await getTailscaleTunnelStatus(), + }; + } + + const tunnelUrl = + funnelResult.tunnelUrl || (await getLiveTunnelUrl((await resolveBinary()).binaryPath)); + if (!tunnelUrl) { + throw new Error("Failed to determine the Tailscale Funnel URL"); + } + + await updateSettings({ + tailscaleEnabled: true, + tailscaleUrl: tunnelUrl, + }); + await updateStateFile({ + tunnelUrl, + lastError: null, + }); + + return { + success: true, + tunnelUrl, + apiUrl: getTailscaleApiUrl(tunnelUrl), + status: await getTailscaleTunnelStatus(), + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to enable Tailscale Funnel"; + await updateStateFile({ lastError: message }); + throw error; + } +} + +export async function disableTailscaleTunnel({ + sudoPassword, +}: { + sudoPassword?: string; +} = {}) { + const normalizedPassword = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; + if (normalizedPassword) { + setCachedPassword(normalizedPassword); + } + + try { + await stopTailscaleFunnel(); + await stopTailscaleDaemon({ sudoPassword: normalizedPassword }); + await updateSettings({ + tailscaleEnabled: false, + tailscaleUrl: "", + }); + await updateStateFile({ + tunnelUrl: null, + lastError: null, + }); + return { + success: true, + status: await getTailscaleTunnelStatus(), + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to disable Tailscale Funnel"; + await updateStateFile({ lastError: message }); + throw error; + } +} + +function createStreamLogger(onProgress: ((message: string) => void) | undefined) { + return (chunk: Buffer | string) => { + const text = chunk.toString(); + const lines = text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines) { + onProgress?.(line); + } + }; +} + +async function installTailscaleMac(password: string, onProgress?: (message: string) => void) { + if (await hasBrew()) { + await new Promise((resolve, reject) => { + const child = spawn("brew", ["install", "tailscale"], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + }); + const log = createStreamLogger(onProgress); + child.stdout.on("data", log); + child.stderr.on("data", log); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`brew install failed with code ${code}`)); + }); + child.on("error", reject); + }); + return; + } + + if (!password.trim()) { + throw new Error("Sudo password required to install Tailscale"); + } + + const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; + const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); + + await new Promise((resolve, reject) => { + const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + }); + child.stderr.on("data", createStreamLogger(onProgress)); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error("Failed to download the Tailscale package")); + }); + child.on("error", reject); + }); + + await new Promise((resolve, reject) => { + const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + env: buildExecEnv(), + }); + const log = createStreamLogger(onProgress); + let stderr = ""; + child.stdin.write(`${password}\n`); + child.stdin.end(); + child.stdout.on("data", log); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + log(chunk); + }); + child.on("close", async (code) => { + try { + await fsPromises.unlink(pkgPath); + } catch { + // Ignore cleanup errors. + } + if (code === 0) resolve(); + else reject(new Error(stderr.trim() || `installer exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +async function installTailscaleLinux(password: string, onProgress?: (message: string) => void) { + if (!password.trim()) { + throw new Error("Sudo password required to install Tailscale"); + } + + await new Promise((resolve, reject) => { + const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + }); + + let scriptContent = ""; + let downloadError = ""; + + curlChild.stdout.on("data", (chunk) => { + scriptContent += chunk.toString(); + }); + curlChild.stderr.on("data", (chunk) => { + downloadError += chunk.toString(); + createStreamLogger(onProgress)(chunk); + }); + + curlChild.on("close", (code) => { + if (code !== 0) { + reject(new Error(downloadError.trim() || "Failed to download the Tailscale installer")); + return; + } + + const child = spawn("sudo", ["-S", "sh"], { + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + env: buildExecEnv(), + }); + + let stderr = ""; + const log = createStreamLogger(onProgress); + child.stdin.write(`${password}\n`); + child.stdin.write(scriptContent); + child.stdin.end(); + child.stdout.on("data", log); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + log(chunk); + }); + child.on("close", (installCode) => { + if (installCode === 0) resolve(); + else reject(new Error(stderr.trim() || `install.sh exited with code ${installCode}`)); + }); + child.on("error", reject); + }); + + curlChild.on("error", reject); + }); +} + +async function installTailscaleWindows(onProgress?: (message: string) => void) { + const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; + const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); + + await new Promise((resolve, reject) => { + const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + }); + child.stderr.on("data", createStreamLogger(onProgress)); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error("Failed to download the Tailscale installer")); + }); + child.on("error", reject); + }); + + await new Promise((resolve, reject) => { + const child = spawn( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Start-Process msiexec -ArgumentList '/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart' -Verb RunAs -Wait`, + ], + { + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: buildExecEnv(), + } + ); + const log = createStreamLogger(onProgress); + child.stdout.on("data", log); + child.stderr.on("data", log); + child.on("close", async (code) => { + try { + await fsPromises.unlink(msiPath); + } catch { + // Ignore cleanup errors. + } + if (code === 0) resolve(); + else reject(new Error(`msiexec exited with code ${code}`)); + }); + child.on("error", reject); + }); +} + +export async function installTailscale({ + sudoPassword, + onProgress, +}: { + sudoPassword?: string; + onProgress?: (message: string) => void; +} = {}) { + if (!isSupportedPlatform()) { + throw new Error(`Unsupported platform for Tailscale install: ${process.platform}`); + } + + const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; + if (password) { + setCachedPassword(password); + } + + onProgress?.("Checking existing Tailscale installation..."); + const existingBinary = await resolveBinary(); + if (existingBinary.binaryPath) { + onProgress?.("Tailscale is already installed."); + } else if (IS_WINDOWS) { + onProgress?.("Downloading and installing Tailscale for Windows..."); + await installTailscaleWindows(onProgress); + } else if (IS_MAC) { + onProgress?.("Installing Tailscale on macOS..."); + await installTailscaleMac(password, onProgress); + } else if (IS_LINUX) { + onProgress?.("Installing Tailscale on Linux..."); + await installTailscaleLinux(password, onProgress); + } + + try { + onProgress?.("Ensuring the Tailscale daemon is available..."); + await startTailscaleDaemon({ sudoPassword: password }); + } catch (error) { + onProgress?.( + `Install completed, but the daemon still needs manual attention: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const status = await getTailscaleTunnelStatus(); + await updateStateFile({ + binaryPath: status.binaryPath, + installSource: status.installSource, + lastError: null, + installedAt: new Date().toISOString(), + }); + + return status; +} diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 461303c37c..139cb3c571 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -12,6 +12,7 @@ import { } from "@/lib/localDb"; import { syncToCloud } from "@/lib/cloudSync"; import { setQuotaCache } from "@/domain/quotaCache"; +import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; import { getMachineId } from "@/shared/utils/machine"; import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { getExecutor } from "@omniroute/open-sse/executors/index.ts"; @@ -33,6 +34,13 @@ interface ProviderConnectionLike { providerSpecificData?: JsonRecord; testStatus?: string; isActive?: boolean; + lastError?: string | null; + lastErrorAt?: string | null; + lastErrorType?: string | null; + lastErrorSource?: string | null; + errorCode?: string | number | null; + rateLimitedUntil?: string | null; + backoffLevel?: number; } const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt"]); @@ -187,6 +195,20 @@ async function syncExpiredStatusIfNeeded(connection: ProviderConnectionLike, usa } } +async function syncClaudeExtraUsageStateIfNeeded( + connection: ProviderConnectionLike, + usage: JsonRecord +): Promise { + const update = buildClaudeExtraUsageConnectionUpdate(connection, usage); + if (!update) return connection; + + await updateProviderConnection(connection.id, update); + return { + ...connection, + ...update, + }; +} + export function getProviderLimitsSyncIntervalMinutes(): number { const raw = Number.parseInt(process.env.PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES ?? "", 10); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES; @@ -233,6 +255,7 @@ export async function fetchLiveProviderLimits(connectionId: string): Promise<{ setQuotaCache(connectionId, connection.provider, usage.quotas); } await syncExpiredStatusIfNeeded(connection, usage); + connection = await syncClaudeExtraUsageStateIfNeeded(connection, usage); return { connection, usage }; } @@ -291,6 +314,7 @@ export async function fetchLiveProviderLimits(connectionId: string): Promise<{ setQuotaCache(connectionId, connection.provider, result.usage.quotas); } await syncExpiredStatusIfNeeded(connection, result.usage); + connection = await syncClaudeExtraUsageStateIfNeeded(connection, result.usage); return { connection, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 59ab666a6d..546086c878 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -94,6 +94,27 @@ export const WEB_COOKIE_PROVIDERS = { website: "https://www.perplexity.ai", authHint: "Paste your __Secure-next-auth.session-token cookie value from perplexity.ai", }, + "blackbox-web": { + id: "blackbox-web", + alias: "bb-web", + name: "Blackbox Web (Subscription)", + icon: "view_in_ar", + color: "#1A1A2E", + textIcon: "BW", + website: "https://app.blackbox.ai", + authHint: + "Paste your __Secure-authjs.session-token value or full cookie header from app.blackbox.ai", + }, + "muse-spark-web": { + id: "muse-spark-web", + alias: "ms-web", + name: "Muse Spark Web (Meta AI)", + icon: "auto_awesome", + color: "#0866FF", + textIcon: "MS", + website: "https://www.meta.ai", + authHint: "Paste your abra_sess value or full cookie header from meta.ai", + }, }; // API Key Providers diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index a7f7c52b08..37a0876da1 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -61,6 +61,24 @@ function validateProviderSpecificData( }); } + const blockExtraUsage = data.blockExtraUsage; + if (blockExtraUsage !== undefined && typeof blockExtraUsage !== "boolean") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.blockExtraUsage must be a boolean", + path: ["blockExtraUsage"], + }); + } + + const autoFetchModels = data.autoFetchModels; + if (autoFetchModels !== undefined && typeof autoFetchModels !== "boolean") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.autoFetchModels must be a boolean", + path: ["autoFetchModels"], + }); + } + const requestDefaults = data.requestDefaults; if (requestDefaults !== undefined) { if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) { diff --git a/tests/unit/antigravity-tool-cloaking.test.ts b/tests/unit/antigravity-tool-cloaking.test.ts new file mode 100644 index 0000000000..ccdb0cca96 --- /dev/null +++ b/tests/unit/antigravity-tool-cloaking.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + AG_DECOY_TOOLS, + AG_TOOL_SUFFIX, + cloakAntigravityToolPayload, +} from "../../open-sse/config/toolCloaking.ts"; + +test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools and injects decoys", () => { + const payload = { + request: { + tools: [ + { + functionDeclarations: [ + { + name: "workspace_read", + description: "Read a file", + parameters: { type: "OBJECT", properties: {} }, + }, + { + name: "run_command", + description: "Native tool should stay visible", + parameters: { type: "OBJECT", properties: {} }, + }, + ], + }, + ], + contents: [ + { + role: "model", + parts: [{ functionCall: { name: "workspace_read", args: { path: "/tmp/a" } } }], + }, + { + role: "user", + parts: [{ functionResponse: { name: "workspace_read", response: { ok: true } } }], + }, + ], + }, + }; + + const result = cloakAntigravityToolPayload(payload); + const declarations = (result.body.request.tools?.[0] as any)?.functionDeclarations || []; + const names = declarations.map((tool: { name: string }) => tool.name); + + assert.ok(names.includes(`workspace_read${AG_TOOL_SUFFIX}`)); + assert.ok(names.includes("run_command")); + assert.ok(names.includes("browser_subagent")); + assert.equal( + result.body.request.contents[0].parts[0].functionCall.name, + `workspace_read${AG_TOOL_SUFFIX}` + ); + assert.equal( + result.body.request.contents[1].parts[0].functionResponse.name, + `workspace_read${AG_TOOL_SUFFIX}` + ); + assert.equal(result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`), "workspace_read"); + assert.equal( + declarations.filter((tool: { name: string }) => tool.name === "browser_subagent").length, + 1 + ); + assert.ok(AG_DECOY_TOOLS.length > 20); +}); + +test("cloakAntigravityToolPayload composes namespace sanitization maps with Antigravity cloaking", () => { + const payload = { + _toolNameMap: new Map([["workspace_read", "mcp__filesystem__workspace_read"]]), + request: { + tools: [ + { + functionDeclarations: [ + { + name: "workspace_read", + description: "Read a file", + parameters: { type: "OBJECT", properties: {} }, + }, + ], + }, + ], + contents: [], + }, + }; + + const result = cloakAntigravityToolPayload(payload); + + assert.equal( + result.toolNameMap?.get(`workspace_read${AG_TOOL_SUFFIX}`), + "mcp__filesystem__workspace_read" + ); +}); diff --git a/tests/unit/blackbox-web.test.ts b/tests/unit/blackbox-web.test.ts new file mode 100644 index 0000000000..138638eaf9 --- /dev/null +++ b/tests/unit/blackbox-web.test.ts @@ -0,0 +1,271 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { BlackboxWebExecutor, normalizeBlackboxCookieHeader } = + await import("../../open-sse/executors/blackbox-web.ts"); +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); + +function mockTextStream(text: string) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +function mockFetch(status: number, text: string) { + const original = globalThis.fetch; + globalThis.fetch = async () => + new Response(mockTextStream(text), { + status, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + return () => { + globalThis.fetch = original; + }; +} + +function mockFetchCapture(status = 200, text = "Hello from Blackbox") { + const original = globalThis.fetch; + let capturedUrl: string | null = null; + let capturedHeaders: Record = {}; + let capturedBody: Record = {}; + + globalThis.fetch = async (url: any, opts: any) => { + capturedUrl = String(url); + capturedHeaders = opts?.headers || {}; + capturedBody = JSON.parse(opts?.body || "{}"); + return new Response(mockTextStream(text), { + status, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + }; + + return { + restore: () => { + globalThis.fetch = original; + }, + get url() { + return capturedUrl; + }, + get headers() { + return capturedHeaders; + }, + get body() { + return capturedBody; + }, + }; +} + +test("BlackboxWebExecutor is registered in executor index", () => { + assert.ok(hasSpecializedExecutor("blackbox-web")); + assert.ok(hasSpecializedExecutor("bb-web")); + const executor = getExecutor("blackbox-web"); + const alias = getExecutor("bb-web"); + assert.ok(executor instanceof BlackboxWebExecutor); + assert.ok(alias instanceof BlackboxWebExecutor); +}); + +test("BlackboxWebExecutor sets correct provider name", () => { + const executor = new BlackboxWebExecutor(); + assert.equal(executor.getProvider(), "blackbox-web"); +}); + +test("Non-streaming: plain text response becomes OpenAI completion", async () => { + const restore = mockFetch(200, "Hello from Blackbox"); + try { + const executor = new BlackboxWebExecutor(); + const result = await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: { apiKey: "bb-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + const json = (await result.response.json()) as any; + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.role, "assistant"); + assert.equal(json.choices[0].message.content, "Hello from Blackbox"); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.id.startsWith("chatcmpl-blackbox-")); + assert.ok(json.usage.total_tokens > 0); + } finally { + restore(); + } +}); + +test("Streaming: produces valid SSE chunks", async () => { + const restore = mockFetch(200, "streamed answer"); + try { + const executor = new BlackboxWebExecutor(); + const result = await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [{ role: "user", content: "hello" }], stream: true }, + stream: true, + credentials: { apiKey: "bb-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + + const text = await result.response.text(); + const lines = text.split("\n").filter((line) => line.startsWith("data: ")); + assert.ok(lines.length >= 3, `Expected at least 3 SSE data lines, got ${lines.length}`); + + const first = JSON.parse(lines[0].slice(6)); + assert.equal(first.choices[0].delta.role, "assistant"); + + const second = JSON.parse(lines[1].slice(6)); + assert.equal(second.choices[0].delta.content, "streamed answer"); + + const lastLine = text.trim().split("\n").filter(Boolean).pop(); + assert.equal(lastLine, "data: [DONE]"); + } finally { + restore(); + } +}); + +test("Error: 401 returns auth error", async () => { + const restore = mockFetch(401, "unauthorized"); + try { + const executor = new BlackboxWebExecutor(); + const result = await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "expired-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 401); + const json = (await result.response.json()) as any; + assert.match(json.error.message, /auth failed/i); + assert.match(json.error.message, /session cookie/i); + } finally { + restore(); + } +}); + +test("Error: 429 returns rate limit message", async () => { + const restore = mockFetch(429, "rate limited"); + try { + const executor = new BlackboxWebExecutor(); + const result = await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "bb-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 429); + const json = (await result.response.json()) as any; + assert.match(json.error.message, /rate limited/i); + } finally { + restore(); + } +}); + +test("Error: empty messages returns 400", async () => { + const executor = new BlackboxWebExecutor(); + const result = await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [] }, + stream: false, + credentials: { apiKey: "bb-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 400); +}); + +test("Cookie normalization supports raw tokens, prefixed tokens and full headers", () => { + assert.equal( + normalizeBlackboxCookieHeader("raw-session-token"), + "__Secure-authjs.session-token=raw-session-token" + ); + assert.equal( + normalizeBlackboxCookieHeader("cookie:raw-session-token"), + "__Secure-authjs.session-token=raw-session-token" + ); + assert.equal( + normalizeBlackboxCookieHeader("__Secure-authjs.session-token=token; other=value"), + "__Secure-authjs.session-token=token; other=value" + ); +}); + +test("Request: posts to correct Blackbox endpoint with normalized cookie", async () => { + const cap = mockFetchCapture(); + try { + const executor = new BlackboxWebExecutor(); + await executor.execute({ + model: "openai/gpt-5.4", + body: { messages: [{ role: "user", content: "test" }], stream: false }, + stream: false, + credentials: { apiKey: "raw-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(cap.url, "https://app.blackbox.ai/api/chat"); + assert.equal(cap.headers.Cookie, "__Secure-authjs.session-token=raw-session-token"); + assert.equal(cap.headers.Origin, "https://app.blackbox.ai"); + assert.match(String(cap.headers.Referer), /^https:\/\/app\.blackbox\.ai\/chat\//); + } finally { + cap.restore(); + } +}); + +test("Request: payload carries model selection and web app defaults", async () => { + const cap = mockFetchCapture(); + try { + const executor = new BlackboxWebExecutor(); + await executor.execute({ + model: "anthropic/claude-opus-4.7", + body: { + messages: [ + { role: "system", content: "Be concise" }, + { role: "user", content: "Implement this" }, + ], + max_tokens: 2048, + stream: false, + }, + stream: false, + credentials: { apiKey: "__Secure-authjs.session-token=token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(cap.body.userSelectedModel, "anthropic/claude-opus-4.7"); + assert.equal(cap.body.userSelectedAgent, "VscodeAgent"); + assert.equal(cap.body.codeModelMode, true); + assert.equal(cap.body.maxTokens, 2048); + assert.equal(Array.isArray(cap.body.messages), true); + assert.match(String((cap.body.messages as any[])[0].content), /System instructions:/); + } finally { + cap.restore(); + } +}); + +test("Provider registry: blackbox-web models are exposed", async () => { + const { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } = + await import("../../open-sse/config/providerModels.ts"); + const models = getModelsByProviderId("blackbox-web"); + assert.ok(models, "blackbox-web should exist in PROVIDER_MODELS"); + assert.equal(PROVIDER_ID_TO_ALIAS["blackbox-web"], "bb-web"); + const ids = models.map((model: any) => model.id); + assert.ok(ids.includes("openai/gpt-5.4")); + assert.ok(ids.includes("anthropic/claude-opus-4.7")); + assert.ok(ids.includes("moonshotai/kimi-k2.6")); + assert.ok(ids.includes("blackbox/encrypted")); +}); diff --git a/tests/unit/claude-extra-usage.test.ts b/tests/unit/claude-extra-usage.test.ts new file mode 100644 index 0000000000..7d9d93a7f5 --- /dev/null +++ b/tests/unit/claude-extra-usage.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const claudeExtraUsage = await import("../../src/lib/providers/claudeExtraUsage.ts"); +const { normalizeProviderSpecificData } = + await import("../../src/lib/providers/requestDefaults.ts"); +const { updateProviderConnectionSchema } = await import("../../src/shared/validation/schemas.ts"); + +function futureIso(ms = 60_000) { + return new Date(Date.now() + ms).toISOString(); +} + +test("Claude extra-usage blocking defaults to enabled and validates provider payloads", () => { + assert.equal(claudeExtraUsage.isClaudeExtraUsageBlockEnabled("claude", {}), true); + assert.equal( + claudeExtraUsage.isClaudeExtraUsageBlockEnabled("claude", { blockExtraUsage: false }), + false + ); + assert.equal( + claudeExtraUsage.isClaudeExtraUsageBlockEnabled("openai", { blockExtraUsage: false }), + false + ); + + assert.deepEqual(normalizeProviderSpecificData("claude", { blockExtraUsage: "nope", tag: "x" }), { + tag: "x", + }); + + const valid = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { blockExtraUsage: false }, + }); + const invalid = updateProviderConnectionSchema.safeParse({ + providerSpecificData: { blockExtraUsage: "false" }, + }); + + assert.equal(valid.success, true); + assert.equal(invalid.success, false); +}); + +test("Claude extra-usage state builds an unavailable cooldown update from queued usage", () => { + const sessionReset = futureIso(180_000); + const weeklyReset = futureIso(360_000); + + const update = claudeExtraUsage.buildClaudeExtraUsageConnectionUpdate( + { + provider: "claude", + providerSpecificData: { blockExtraUsage: true }, + backoffLevel: 0, + }, + { + extraUsage: { queued: true, billingAmount: 0.5 }, + quotas: { + "session (5h)": { remainingPercentage: 0, resetAt: sessionReset }, + "weekly (7d)": { remainingPercentage: 62, resetAt: weeklyReset }, + }, + } + ); + + assert.equal(update?.testStatus, "unavailable"); + assert.equal(update?.lastErrorType, "quota_exhausted"); + assert.equal(update?.lastErrorSource, claudeExtraUsage.CLAUDE_EXTRA_USAGE_ERROR_SOURCE); + assert.equal(update?.errorCode, 429); + assert.equal(update?.rateLimitedUntil, sessionReset); + assert.equal(update?.backoffLevel, 1); +}); + +test("Claude extra-usage state clears only when a trusted snapshot says queued usage ended", () => { + const currentState = { + provider: "claude", + providerSpecificData: { blockExtraUsage: true }, + testStatus: "unavailable", + lastError: claudeExtraUsage.CLAUDE_EXTRA_USAGE_ERROR_MESSAGE, + lastErrorSource: claudeExtraUsage.CLAUDE_EXTRA_USAGE_ERROR_SOURCE, + lastErrorType: "quota_exhausted", + rateLimitedUntil: futureIso(180_000), + backoffLevel: 1, + }; + + const cleared = claudeExtraUsage.buildClaudeExtraUsageConnectionUpdate(currentState, { + extraUsage: null, + quotas: { + "session (5h)": { remainingPercentage: 42, resetAt: futureIso(120_000) }, + }, + }); + const unchanged = claudeExtraUsage.buildClaudeExtraUsageConnectionUpdate(currentState, { + message: "Claude connected. Unable to fetch usage: timeout", + }); + + assert.equal(cleared?.testStatus, "active"); + assert.equal(cleared?.lastError, null); + assert.equal(cleared?.lastErrorSource, null); + assert.equal(cleared?.rateLimitedUntil, null); + assert.equal(cleared?.backoffLevel, 0); + assert.equal(unchanged, null); +}); diff --git a/tests/unit/error-config.test.ts b/tests/unit/error-config.test.ts new file mode 100644 index 0000000000..d2b890a106 --- /dev/null +++ b/tests/unit/error-config.test.ts @@ -0,0 +1,48 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + ERROR_TYPES, + DEFAULT_ERROR_MESSAGES, + BACKOFF_CONFIG, + COOLDOWN_MS, + calculateBackoffCooldown, + findMatchingErrorRule, + getDefaultErrorMessage, + getErrorInfo, + matchErrorRuleByStatus, + matchErrorRuleByText, +} = await import("../../open-sse/config/errorConfig.ts"); + +test("errorConfig exposes centralized client-facing status metadata", () => { + assert.deepEqual(ERROR_TYPES[402], { + type: "billing_error", + code: "payment_required", + }); + assert.equal(DEFAULT_ERROR_MESSAGES[406], "Model not supported"); + assert.equal(getDefaultErrorMessage(999), "An error occurred"); + assert.deepEqual(getErrorInfo(504), { + type: "server_error", + code: "gateway_timeout", + }); +}); + +test("errorConfig resolves text rules before status rules", () => { + const textRule = matchErrorRuleByText("Rate limit reached"); + assert.equal(textRule?.id, "rate_limit"); + assert.equal(textRule?.backoff, true); + + const statusRule = matchErrorRuleByStatus(404); + assert.equal(statusRule?.id, "status_404"); + assert.equal(statusRule?.cooldownMs, COOLDOWN_MS.notFound); + + const combinedRule = findMatchingErrorRule(429, "Request not allowed by upstream"); + assert.equal(combinedRule?.id, "request_not_allowed"); + assert.equal(combinedRule?.cooldownMs, COOLDOWN_MS.requestNotAllowed); +}); + +test("errorConfig preserves the existing exponential backoff policy", () => { + assert.equal(calculateBackoffCooldown(0), BACKOFF_CONFIG.base); + assert.equal(calculateBackoffCooldown(3), BACKOFF_CONFIG.base * 8); + assert.equal(calculateBackoffCooldown(BACKOFF_CONFIG.maxLevel + 20), BACKOFF_CONFIG.max); +}); diff --git a/tests/unit/model-sync-route.test.ts b/tests/unit/model-sync-route.test.ts index 320670e36d..c479bc37e5 100644 --- a/tests/unit/model-sync-route.test.ts +++ b/tests/unit/model-sync-route.test.ts @@ -63,7 +63,10 @@ test("model sync route skips success log when fetched models do not change store const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ models: [{ id: "custom-model-1", name: "Custom Model 1" }], }); @@ -103,7 +106,10 @@ test("model sync route stores the real provider while keeping the account label" const originalFetch = globalThis.fetch; globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ models: [{ id: "custom-model-2", name: "Custom Model 2" }], }); @@ -184,7 +190,10 @@ test("model sync route propagates upstream failures and records an error log ent }); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ error: "Provider upstream unavailable" }, { status: 502 }); }; @@ -217,7 +226,10 @@ test("model sync route falls back to the upstream HTTP status when the models pa }); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({}, { status: 429 }); }; @@ -257,7 +269,10 @@ test("model sync route preserves previously synced models when the upstream omit ]); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({}); }; @@ -298,7 +313,10 @@ test("model sync route writes synced available models for Gemini connections", a }); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ models: [ { @@ -359,6 +377,57 @@ test("model sync route writes synced available models for Gemini connections", a assert.equal(logs[0].status, 200); }); +test("model sync route writes synced available models for non-Gemini providers too", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "opencode-go", + authType: "apikey", + name: "OpenCode Go Sync", + apiKey: "opencode-go-key", + }); + + globalThis.fetch = async (url) => { + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); + return Response.json({ + models: [ + { + id: "glm-5.1", + name: "GLM 5.1", + supportedEndpoints: ["chat"], + inputTokenLimit: 262144, + }, + ], + }); + }; + + const response = await modelSyncRoute.POST( + new Request(`http://localhost/api/providers/${connection.id}/sync-models`, { + method: "POST", + headers: scheduler.buildModelSyncInternalHeaders(), + }), + { params: { id: connection.id } } + ); + const body = (await response.json()) as any; + const synced = await modelsDb.getSyncedAvailableModels("opencode-go"); + + assert.equal(response.status, 200); + assert.equal(body.provider, "opencode-go"); + assert.equal(body.syncedModels, 1); + assert.deepEqual(synced, [ + { + id: "glm-5.1", + name: "GLM 5.1", + source: "api-sync", + supportedEndpoints: ["chat"], + inputTokenLimit: 262144, + }, + ]); +}); + test("model sync route records added, removed, and updated model diffs with fallback identifiers", async () => { await resetStorage(); @@ -383,7 +452,10 @@ test("model sync route records added, removed, and updated model diffs with fall ]); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ models: [ { @@ -458,7 +530,10 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo await localDb.setModelAlias("router-v2", "other-provider/router-v2"); globalThis.fetch = async (url, init = {}) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); assert.equal(init.headers.cookie, "session=test-cookie"); assert.equal( init.headers[scheduler.getModelSyncInternalAuthHeaderName()], @@ -536,7 +611,10 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi await localDb.setModelAlias("sonnet-4-6", "some-other-provider/sonnet-4-6"); globalThis.fetch = async (url) => { - assert.equal(String(url), `http://localhost/api/providers/${connection.id}/models`); + assert.equal( + String(url), + `http://localhost/api/providers/${connection.id}/models?refresh=true` + ); return Response.json({ models: [{ id: "sonnet-4-6", name: "Sonnet 4.6" }], }); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index e541353786..418b5662a6 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -381,6 +381,34 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a assert.equal(chatModel.context_length, 8192); }); +test("v1 models catalog includes synced non-Gemini provider models from discovery cache", async () => { + const connection = await seedConnection("opencode-go", { + name: "opencode-go-synced", + apiKey: "go-key", + }); + + await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", (connection as any).id, [ + { + id: "glm-5.1", + name: "GLM 5.1", + source: "api-sync", + supportedEndpoints: ["chat"], + inputTokenLimit: 262144, + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const syncedModel = body.data.find((item) => item.id === "opencode-go/glm-5.1"); + + assert.equal(response.status, 200); + assert.ok(syncedModel); + assert.equal(syncedModel.owned_by, "opencode-go"); + assert.equal(syncedModel.context_length, 262144); +}); + test("v1 models catalog includes media, moderation, rerank, video, and music models for active providers", async () => { await seedConnection("openai", { name: "openai-media" }); await seedConnection("cohere", { name: "cohere-rerank" }); diff --git a/tests/unit/muse-spark-web.test.ts b/tests/unit/muse-spark-web.test.ts new file mode 100644 index 0000000000..83e574a239 --- /dev/null +++ b/tests/unit/muse-spark-web.test.ts @@ -0,0 +1,327 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { MuseSparkWebExecutor, normalizeMetaAiCookieHeader } = + await import("../../open-sse/executors/muse-spark-web.ts"); +const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts"); + +function mockTextStream(text: string) { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(text)); + controller.close(); + }, + }); +} + +function metaAiSseText(events: Array>) { + const frames = [":"]; + for (const event of events) { + frames.push("event: next"); + frames.push(`data: ${JSON.stringify({ data: { sendMessageStream: event } })}`); + frames.push(""); + } + frames.push("event: complete"); + frames.push("data:"); + frames.push(""); + return frames.join("\n"); +} + +function mockFetch(status: number, text: string) { + const original = globalThis.fetch; + globalThis.fetch = async () => + new Response(mockTextStream(text), { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + return () => { + globalThis.fetch = original; + }; +} + +function mockFetchCapture(status = 200, text = metaAiSseText([])) { + const original = globalThis.fetch; + let capturedUrl: string | null = null; + let capturedHeaders: Record = {}; + let capturedBody: Record = {}; + + globalThis.fetch = async (url: any, opts: any) => { + capturedUrl = String(url); + capturedHeaders = opts?.headers || {}; + capturedBody = JSON.parse(opts?.body || "{}"); + return new Response(mockTextStream(text), { + status, + headers: { "Content-Type": "text/event-stream" }, + }); + }; + + return { + restore: () => { + globalThis.fetch = original; + }, + get url() { + return capturedUrl; + }, + get headers() { + return capturedHeaders; + }, + get body() { + return capturedBody; + }, + }; +} + +test("MuseSparkWebExecutor is registered in executor index", () => { + assert.ok(hasSpecializedExecutor("muse-spark-web")); + assert.ok(hasSpecializedExecutor("ms-web")); + const executor = getExecutor("muse-spark-web"); + const alias = getExecutor("ms-web"); + assert.ok(executor instanceof MuseSparkWebExecutor); + assert.ok(alias instanceof MuseSparkWebExecutor); +}); + +test("MuseSparkWebExecutor sets correct provider name", () => { + const executor = new MuseSparkWebExecutor(); + assert.equal(executor.getProvider(), "muse-spark-web"); +}); + +test("Non-streaming: Meta SSE becomes OpenAI completion", async () => { + const restore = mockFetch( + 200, + metaAiSseText([ + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "Hello ", + streamingState: "STREAMING", + contentRenderer: { __typename: "TextContentRenderer", text: "Hello " }, + }, + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "Hello from Muse Spark", + streamingState: "DONE", + contentRenderer: { __typename: "TextContentRenderer", text: "Hello from Muse Spark" }, + }, + ]) + ); + + try { + const executor = new MuseSparkWebExecutor(); + const result = await executor.execute({ + model: "muse-spark", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + credentials: { apiKey: "abra-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + const json = (await result.response.json()) as any; + assert.equal(json.object, "chat.completion"); + assert.equal(json.choices[0].message.role, "assistant"); + assert.equal(json.choices[0].message.content, "Hello from Muse Spark"); + assert.equal(json.choices[0].finish_reason, "stop"); + assert.ok(json.id.startsWith("chatcmpl-meta-")); + } finally { + restore(); + } +}); + +test("Streaming: produces valid SSE chunks", async () => { + const restore = mockFetch( + 200, + metaAiSseText([ + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "Hello ", + streamingState: "STREAMING", + }, + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "Hello from Muse Spark", + streamingState: "DONE", + }, + ]) + ); + + try { + const executor = new MuseSparkWebExecutor(); + const result = await executor.execute({ + model: "muse-spark-thinking", + body: { messages: [{ role: "user", content: "hello" }], stream: true }, + stream: true, + credentials: { apiKey: "abra-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 200); + assert.equal(result.response.headers.get("Content-Type"), "text/event-stream"); + + const text = await result.response.text(); + const lines = text.split("\n").filter((line) => line.startsWith("data: ")); + assert.ok(lines.length >= 4, `Expected at least 4 SSE data lines, got ${lines.length}`); + + const first = JSON.parse(lines[0].slice(6)); + assert.equal(first.choices[0].delta.role, "assistant"); + + const second = JSON.parse(lines[1].slice(6)); + assert.equal(second.choices[0].delta.content, "Hello "); + + const third = JSON.parse(lines[2].slice(6)); + assert.equal(third.choices[0].delta.content, "from Muse Spark"); + + const lastLine = text.trim().split("\n").filter(Boolean).pop(); + assert.equal(lastLine, "data: [DONE]"); + } finally { + restore(); + } +}); + +test("Error: auth failure from Meta SSE returns cookie error", async () => { + const restore = mockFetch( + 200, + metaAiSseText([ + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "Authentication required to send messages", + streamingState: "ERROR", + error: { message: "Authentication required to send messages", code: null }, + contentRenderer: { + __typename: "TextContentRenderer", + text: "Authentication required to send messages", + }, + }, + ]) + ); + + try { + const executor = new MuseSparkWebExecutor(); + const result = await executor.execute({ + model: "muse-spark", + body: { messages: [{ role: "user", content: "hi" }] }, + stream: false, + credentials: { apiKey: "expired-cookie" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(result.response.status, 401); + const json = (await result.response.json()) as any; + assert.match(json.error.message, /meta ai auth failed/i); + assert.match(json.error.message, /abra_sess/i); + } finally { + restore(); + } +}); + +test("Cookie normalization supports raw tokens, prefixed tokens and full headers", () => { + assert.equal(normalizeMetaAiCookieHeader("raw-session-token"), "abra_sess=raw-session-token"); + assert.equal( + normalizeMetaAiCookieHeader("cookie:raw-session-token"), + "abra_sess=raw-session-token" + ); + assert.equal( + normalizeMetaAiCookieHeader("abra_sess=token; other=value"), + "abra_sess=token; other=value" + ); +}); + +test("Request: posts to correct Meta endpoint with normalized cookie", async () => { + const cap = mockFetchCapture( + 200, + metaAiSseText([ + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "ok", + streamingState: "DONE", + }, + ]) + ); + + try { + const executor = new MuseSparkWebExecutor(); + await executor.execute({ + model: "muse-spark", + body: { messages: [{ role: "user", content: "test" }], stream: false }, + stream: false, + credentials: { apiKey: "raw-session-token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(cap.url, "https://www.meta.ai/api/graphql"); + assert.equal(cap.headers.Cookie, "abra_sess=raw-session-token"); + assert.equal(cap.headers.Accept, "text/event-stream"); + assert.equal(cap.headers.Origin, "https://www.meta.ai"); + assert.equal(cap.headers.Referer, "https://www.meta.ai/"); + } finally { + cap.restore(); + } +}); + +test("Request: payload carries persisted doc id, model mapping and Meta defaults", async () => { + const cap = mockFetchCapture( + 200, + metaAiSseText([ + { + __typename: "AssistantMessage", + id: "meta-msg-1", + content: "ok", + streamingState: "DONE", + }, + ]) + ); + + try { + const executor = new MuseSparkWebExecutor(); + await executor.execute({ + model: "muse-spark-contemplating", + body: { + messages: [ + { role: "system", content: "Be concise" }, + { role: "assistant", content: "Previous answer" }, + { role: "user", content: "Implement this" }, + ], + stream: false, + }, + stream: false, + credentials: { apiKey: "abra_sess=token" }, + signal: AbortSignal.timeout(10000), + log: null, + }); + + assert.equal(cap.body.doc_id, "078dfdff6fb0d420d8011b49073e6886"); + assert.equal((cap.body.variables as any).mode, "think_hard"); + assert.equal((cap.body.variables as any).currentBranchPath, "0"); + assert.equal((cap.body.variables as any).entryPoint, "KADABRA__CHAT__UNIFIED_INPUT_BAR"); + assert.equal((cap.body.variables as any).promptEditType, null); + assert.match(String((cap.body.variables as any).content), /system: Be concise/i); + assert.match(String((cap.body.variables as any).content), /assistant: Previous answer/i); + assert.match(String((cap.body.variables as any).content), /Implement this/); + assert.match(String((cap.body.variables as any).conversationId), /^c\./); + assert.match(String((cap.body.variables as any).userEventId), /^e\./); + assert.match(String((cap.body.variables as any).userUniqueMessageId), /^\d+$/); + } finally { + cap.restore(); + } +}); + +test("Provider registry: muse-spark-web models are exposed", async () => { + const { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } = + await import("../../open-sse/config/providerModels.ts"); + const models = getModelsByProviderId("muse-spark-web"); + assert.ok(models, "muse-spark-web should exist in PROVIDER_MODELS"); + assert.equal(PROVIDER_ID_TO_ALIAS["muse-spark-web"], "ms-web"); + const ids = models.map((model: any) => model.id); + assert.ok(ids.includes("muse-spark")); + assert.ok(ids.includes("muse-spark-thinking")); + assert.ok(ids.includes("muse-spark-contemplating")); +}); diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 379cf02c6a..6cb38c7bc7 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -233,12 +233,103 @@ test("provider models route returns the local catalog for new built-in chat-open assert.equal(response.status, 200); assert.equal(body.provider, "deepinfra"); assert.equal(body.source, "local_catalog"); - assert.match(body.warning, /cached catalog/i); + assert.match(body.warning, /local catalog/i); assert.ok(Array.isArray(body.models)); assert.ok(body.models.length > 0); assert.ok(body.models.some((model) => model.id === "Qwen/Qwen3-Coder-480B-A35B-Instruct")); }); +test("provider models route caches discovered opencode-go models per connection", async () => { + const connection = await seedConnection("opencode-go", { + apiKey: "opencode-go-key", + }); + let fetchCalls = 0; + + globalThis.fetch = async (url, init = {}) => { + fetchCalls += 1; + assert.equal(String(url), "https://opencode.ai/zen/go/v1/models"); + assert.equal(init.method, "GET"); + assert.equal(init.headers.Authorization, "Bearer opencode-go-key"); + return Response.json({ + data: [{ id: "glm-5.1", name: "GLM 5.1" }], + }); + }; + + const firstResponse = await callRoute(connection.id); + const firstBody = (await firstResponse.json()) as any; + const cachedModels = await modelsDb.getSyncedAvailableModelsForConnection( + "opencode-go", + connection.id + ); + + assert.equal(firstResponse.status, 200); + assert.equal(firstBody.source, "api"); + assert.deepEqual(firstBody.models, [{ id: "glm-5.1", name: "GLM 5.1" }]); + assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + + globalThis.fetch = async () => { + throw new Error("cached route should not hit upstream"); + }; + + const cachedResponse = await callRoute(connection.id); + const cachedBody = (await cachedResponse.json()) as any; + + assert.equal(cachedResponse.status, 200); + assert.equal(cachedBody.source, "cache"); + assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]); + assert.equal(fetchCalls, 1); +}); + +test("provider models route falls back to cached models when a refresh fails", async () => { + const connection = await seedConnection("opencode-go", { + apiKey: "opencode-go-key", + }); + await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [ + { id: "cached-go", name: "Cached Go", source: "api-sync" }, + ]); + let fetchCalls = 0; + + globalThis.fetch = async () => { + fetchCalls += 1; + return new Response("upstream unavailable", { status: 503 }); + }; + + const response = await callRoute(connection.id, "?refresh=true"); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "cache"); + assert.match(body.warning, /cached catalog/i); + assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "api-sync" }]); + assert.equal(fetchCalls, 1); +}); + +test("provider models route honors autoFetchModels=false and skips remote discovery", async () => { + const connection = await seedConnection("opencode-go", { + apiKey: "opencode-go-key", + providerSpecificData: { + autoFetchModels: false, + }, + }); + let called = false; + + globalThis.fetch = async () => { + called = true; + return Response.json({ + data: [{ id: "glm-5.1", name: "GLM 5.1" }], + }); + }; + + const response = await callRoute(connection.id); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.source, "local_catalog"); + assert.match(body.warning, /auto-fetch disabled/i); + assert.equal(called, false); + assert.ok(body.models.some((model) => model.id === "glm-5")); +}); + test("provider models route validates Gemini CLI credentials before fetching quota buckets", async () => { const missingToken = await seedConnection("gemini-cli", { authType: "oauth", @@ -342,7 +433,7 @@ test("provider models route falls back through all Antigravity discovery endpoin assert.equal(response.status, 200); assert.equal(body.source, "local_catalog"); - assert.match(body.warning, /cached catalog/i); + assert.match(body.warning, /local catalog/i); assert.deepEqual(discoveryUrls, [ "https://cloudcode-pa.googleapis.com/v1internal:models", "https://daily-cloudcode-pa.googleapis.com/v1internal:models", @@ -545,11 +636,13 @@ test("provider models route forwards upstream status codes for generic provider globalThis.fetch = async () => new Response("upstream unavailable", { status: 503 }); const response = await callRoute(connection.id); + const body = (await response.json()) as any; - assert.equal(response.status, 503); - assert.deepEqual(await response.json(), { - error: "Failed to fetch models: 503", - }); + assert.equal(response.status, 200); + assert.equal(body.source, "local_catalog"); + assert.match(body.warning, /local catalog/i); + assert.ok(Array.isArray(body.models)); + assert.ok(body.models.length > 0); }); test("provider models route returns 500 when fetching models throws unexpectedly", async () => { @@ -562,11 +655,11 @@ test("provider models route returns 500 when fetching models throws unexpectedly }; const response = await callRoute(connection.id); + const body = (await response.json()) as any; - assert.equal(response.status, 500); - assert.deepEqual(await response.json(), { - error: "Failed to fetch models", - }); + assert.equal(response.status, 200); + assert.equal(body.source, "local_catalog"); + assert.match(body.warning, /local catalog/i); }); test("provider models route rejects generic providers without any configured token", async () => { @@ -574,11 +667,20 @@ test("provider models route rejects generic providers without any configured tok apiKey: null, accessToken: null, }); + let called = false; + + globalThis.fetch = async () => { + called = true; + return Response.json({ data: [] }); + }; const response = await callRoute(connection.id); + const body = (await response.json()) as any; - assert.equal(response.status, 400); - assert.match((await response.json()).error, /No API key configured/i); + assert.equal(response.status, 200); + assert.equal(body.source, "local_catalog"); + assert.match(body.warning, /local catalog/i); + assert.equal(called, false); }); test("provider models route rejects unsupported providers without a models config", async () => { diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index f0c401c562..e2df96693d 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -10,6 +10,30 @@ test.afterEach(() => { globalThis.fetch = originalFetch; }); +function metaAiSseText(content: string, streamingState = "DONE") { + return `event: next +data: ${JSON.stringify({ + data: { + sendMessageStream: { + __typename: "AssistantMessage", + id: "meta-msg-1", + content, + streamingState, + error: + streamingState === "ERROR" + ? { message: content, code: null, stack: "Error: " + content } + : null, + contentRenderer: { __typename: "TextContentRenderer", text: content }, + }, + }, + })} + +event: complete +data: + +`; +} + test("specialty provider validators cover Deepgram, AssemblyAI, NanoBanana, ElevenLabs and Inworld branches", async () => { globalThis.fetch = async (url, init = {}) => { const target = String(url); @@ -83,7 +107,7 @@ test("specialty providers surface network failures and non-auth upstream failure assert.equal(longcat.error, "longcat offline"); }); -test("web-cookie provider validators accept valid Grok and Perplexity session cookies", async () => { +test("web-cookie provider validators accept valid Grok, Perplexity, Blackbox and Muse Spark session cookies", async () => { const calls = []; globalThis.fetch = async (url, init = {}) => { const target = String(url); @@ -95,6 +119,30 @@ test("web-cookie provider validators accept valid Grok and Perplexity session co if (target.includes("perplexity.ai/rest/sse/perplexity_ask")) { return new Response(JSON.stringify({ ok: true }), { status: 200 }); } + if (target.includes("app.blackbox.ai/api/auth/session")) { + return new Response( + JSON.stringify({ + user: { id: "bb-user-1", email: "premium@example.com" }, + }), + { status: 200 } + ); + } + if (target.includes("app.blackbox.ai/api/check-subscription")) { + return new Response( + JSON.stringify({ + hasActiveSubscription: true, + isTrialSubscription: false, + plan: "pro", + }), + { status: 200 } + ); + } + if (target.includes("meta.ai/api/graphql")) { + return new Response(metaAiSseText("Muse Spark says hello"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } throw new Error(`unexpected fetch: ${target}`); }; @@ -104,15 +152,46 @@ test("web-cookie provider validators accept valid Grok and Perplexity session co provider: "perplexity-web", apiKey: "__Secure-next-auth.session-token=pplx-cookie", }); + const blackbox = await validateProviderApiKey({ + provider: "blackbox-web", + apiKey: "__Secure-authjs.session-token=bb-cookie", + }); + const museSpark = await validateProviderApiKey({ + provider: "muse-spark-web", + apiKey: "abra_sess=meta-cookie", + }); assert.equal(grok.valid, true); assert.equal(perplexity.valid, true); - assert.equal(calls[0].init.headers.Cookie, "sso=grok-cookie"); - assert.equal(calls[1].init.headers.Cookie, "__Secure-next-auth.session-token=pplx-cookie"); + assert.equal(blackbox.valid, true); + assert.equal(museSpark.valid, true); + + const grokCall = calls.find((call) => + call.url.includes("grok.com/rest/app-chat/conversations/new") + ); + const perplexityCall = calls.find((call) => + call.url.includes("perplexity.ai/rest/sse/perplexity_ask") + ); + const blackboxSessionCall = calls.find((call) => + call.url.includes("app.blackbox.ai/api/auth/session") + ); + const blackboxSubscriptionCall = calls.find((call) => + call.url.includes("app.blackbox.ai/api/check-subscription") + ); + const museSparkCall = calls.find((call) => call.url.includes("meta.ai/api/graphql")); + + assert.equal(grokCall?.init.headers.Cookie, "sso=grok-cookie"); + assert.equal(perplexityCall?.init.headers.Cookie, "__Secure-next-auth.session-token=pplx-cookie"); + assert.equal(blackboxSessionCall?.init.headers.Cookie, "__Secure-authjs.session-token=bb-cookie"); + assert.equal( + blackboxSubscriptionCall?.init.headers.Cookie, + "__Secure-authjs.session-token=bb-cookie" + ); + assert.equal(museSparkCall?.init.headers.Cookie, "abra_sess=meta-cookie"); }); -test("web-cookie provider validators surface auth failures for expired session cookies", async () => { - globalThis.fetch = async (url) => { +test("web-cookie provider validators surface auth and subscription failures", async () => { + globalThis.fetch = async (url, init = {}) => { const target = String(url); if (target.includes("grok.com/rest/app-chat/conversations/new")) { return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); @@ -120,6 +199,35 @@ test("web-cookie provider validators surface auth failures for expired session c if (target.includes("perplexity.ai/rest/sse/perplexity_ask")) { return new Response(JSON.stringify({ error: "forbidden" }), { status: 403 }); } + if (target.includes("app.blackbox.ai/api/auth/session")) { + const cookie = (init.headers as Record)?.Cookie || ""; + if (cookie.includes("expired-cookie")) { + return new Response("null", { status: 200 }); + } + return new Response( + JSON.stringify({ + user: { id: "bb-user-2", email: "free@example.com" }, + }), + { status: 200 } + ); + } + if (target.includes("app.blackbox.ai/api/check-subscription")) { + return new Response( + JSON.stringify({ + hasActiveSubscription: false, + isTrialSubscription: false, + previouslySubscribed: true, + plan: "free", + }), + { status: 200 } + ); + } + if (target.includes("meta.ai/api/graphql")) { + return new Response(metaAiSseText("Authentication required to send messages", "ERROR"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + } throw new Error(`unexpected fetch: ${target}`); }; @@ -129,9 +237,24 @@ test("web-cookie provider validators surface auth failures for expired session c provider: "perplexity-web", apiKey: "pplx-cookie", }); + const blackboxExpired = await validateProviderApiKey({ + provider: "blackbox-web", + apiKey: "expired-cookie", + }); + const blackboxNoSubscription = await validateProviderApiKey({ + provider: "blackbox-web", + apiKey: "free-account-cookie", + }); + const museSpark = await validateProviderApiKey({ + provider: "muse-spark-web", + apiKey: "meta-cookie", + }); assert.match(grok.error || "", /Invalid SSO cookie/i); assert.match(perplexity.error || "", /Invalid Perplexity session cookie/i); + assert.match(blackboxExpired.error || "", /Invalid Blackbox session cookie/i); + assert.match(blackboxNoSubscription.error || "", /no active paid subscription/i); + assert.match(museSpark.error || "", /Invalid Meta AI session cookie/i); }); test("search provider validators cover success, client errors, server errors and custom user agent injection", async () => { diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index f98ca74422..e119ba1221 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -219,6 +219,8 @@ test("static catalog entries resolve search, audio, web-cookie and upstream prov const audioProvider = providerPageUtils.resolveDashboardProviderInfo("assemblyai"); const webCookieProvider = providerPageUtils.resolveDashboardProviderInfo("grok-web"); const perplexityWebProvider = providerPageUtils.resolveDashboardProviderInfo("perplexity-web"); + const blackboxWebProvider = providerPageUtils.resolveDashboardProviderInfo("blackbox-web"); + const museSparkWebProvider = providerPageUtils.resolveDashboardProviderInfo("muse-spark-web"); const upstreamProvider = providerPageUtils.resolveDashboardProviderInfo("cliproxyapi"); assert.equal(searchProvider?.category, "search"); @@ -233,6 +235,12 @@ test("static catalog entries resolve search, audio, web-cookie and upstream prov assert.equal(perplexityWebProvider?.category, "web-cookie"); assert.equal(perplexityWebProvider?.name, providers.WEB_COOKIE_PROVIDERS["perplexity-web"].name); + assert.equal(blackboxWebProvider?.category, "web-cookie"); + assert.equal(blackboxWebProvider?.name, providers.WEB_COOKIE_PROVIDERS["blackbox-web"].name); + + assert.equal(museSparkWebProvider?.category, "web-cookie"); + assert.equal(museSparkWebProvider?.name, providers.WEB_COOKIE_PROVIDERS["muse-spark-web"].name); + assert.equal(upstreamProvider?.category, "upstream-proxy"); assert.equal( upstreamProvider?.name, @@ -245,6 +253,8 @@ test("managed provider connection ids include supported static categories and ex assert.equal(providerCatalog.isManagedProviderConnectionId("assemblyai"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("grok-web"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("perplexity-web"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("blackbox-web"), true); + assert.equal(providerCatalog.isManagedProviderConnectionId("muse-spark-web"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("brave-search"), true); assert.equal(providerCatalog.isManagedProviderConnectionId("cliproxyapi"), false); assert.equal(providerCatalog.isManagedProviderConnectionId("claude"), false); @@ -253,6 +263,10 @@ test("managed provider connection ids include supported static categories and ex test("grok-web taxonomy stays web-cookie only and does not leak into api-key entries", () => { assert.equal("grok-web" in providers.APIKEY_PROVIDERS, false); assert.equal("grok-web" in providers.WEB_COOKIE_PROVIDERS, true); + assert.equal("blackbox-web" in providers.APIKEY_PROVIDERS, false); + assert.equal("blackbox-web" in providers.WEB_COOKIE_PROVIDERS, true); + assert.equal("muse-spark-web" in providers.APIKEY_PROVIDERS, false); + assert.equal("muse-spark-web" in providers.WEB_COOKIE_PROVIDERS, true); const apiKeyEntries = providerPageUtils.buildStaticProviderEntries("apikey", () => ({ total: 0, @@ -269,6 +283,14 @@ test("grok-web taxonomy stays web-cookie only and does not leak into api-key ent webCookieEntries.some((entry) => entry.providerId === "grok-web"), true ); + assert.equal( + webCookieEntries.some((entry) => entry.providerId === "blackbox-web"), + true + ); + assert.equal( + webCookieEntries.some((entry) => entry.providerId === "muse-spark-web"), + true + ); }); test("compatible catalog entries keep dynamic compatible metadata", () => { diff --git a/tests/unit/providers-route-managed-catalog.test.ts b/tests/unit/providers-route-managed-catalog.test.ts index 24f9c8e131..607d7fa665 100644 --- a/tests/unit/providers-route-managed-catalog.test.ts +++ b/tests/unit/providers-route-managed-catalog.test.ts @@ -54,6 +54,22 @@ test("providers route accepts managed audio, web-cookie and search providers", a name: "Perplexity Web Session", }, }, + { + provider: "blackbox-web", + body: { + provider: "blackbox-web", + apiKey: "__Secure-authjs.session-token=bb-cookie", + name: "Blackbox Web Session", + }, + }, + { + provider: "muse-spark-web", + body: { + provider: "muse-spark-web", + apiKey: "abra_sess=meta-cookie", + name: "Muse Spark Web Session", + }, + }, { provider: "google-pse-search", body: { diff --git a/tests/unit/tailscaleTunnel.test.ts b/tests/unit/tailscaleTunnel.test.ts new file mode 100644 index 0000000000..43b168277c --- /dev/null +++ b/tests/unit/tailscaleTunnel.test.ts @@ -0,0 +1,221 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-tailscale-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const tailscaleTunnel = await import("../../src/lib/tailscaleTunnel.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const dbCore = await import("../../src/lib/db/core.ts"); +const mitmManager = await import("../../src/mitm/manager.ts"); + +const originalEnv = { + tailscaleBin: process.env.TAILSCALE_BIN, + tailscaledBin: process.env.TAILSCALED_BIN, + statusJson: process.env.TAILSCALE_TEST_STATUS_JSON, + funnelStatusJson: process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON, + funnelOutput: process.env.TAILSCALE_TEST_FUNNEL_OUTPUT, + funnelExitCode: process.env.TAILSCALE_TEST_FUNNEL_EXIT_CODE, + loginOutput: process.env.TAILSCALE_TEST_LOGIN_OUTPUT, + loginExitCode: process.env.TAILSCALE_TEST_LOGIN_EXIT_CODE, +}; + +async function createFakeTailscaleBinary() { + const fakePath = path.join(TEST_DATA_DIR, "fake-tailscale.sh"); + await fs.writeFile( + fakePath, + `#!/usr/bin/env bash +args="$*" +if [[ "$args" == *"funnel status --json"* ]]; then + if [[ -n "$TAILSCALE_TEST_FUNNEL_STATUS_JSON" ]]; then + printf '%s' "$TAILSCALE_TEST_FUNNEL_STATUS_JSON" + exit 0 + fi + exit 1 +fi +if [[ "$args" == *"status --json"* ]]; then + if [[ -n "$TAILSCALE_TEST_STATUS_JSON" ]]; then + printf '%s' "$TAILSCALE_TEST_STATUS_JSON" + exit 0 + fi + exit 1 +fi +if [[ "$args" == *"funnel --bg reset"* ]]; then + exit 0 +fi +if [[ "$args" == *"funnel --bg "* ]]; then + if [[ -n "$TAILSCALE_TEST_FUNNEL_OUTPUT" ]]; then + printf '%s' "$TAILSCALE_TEST_FUNNEL_OUTPUT" + fi + exit "\${TAILSCALE_TEST_FUNNEL_EXIT_CODE:-0}" +fi +if [[ "$args" == *"up --accept-routes"* ]]; then + if [[ -n "$TAILSCALE_TEST_LOGIN_OUTPUT" ]]; then + printf '%s' "$TAILSCALE_TEST_LOGIN_OUTPUT" + fi + exit "\${TAILSCALE_TEST_LOGIN_EXIT_CODE:-0}" +fi +exit 1 +`, + "utf8" + ); + await fs.chmod(fakePath, 0o755); + return fakePath; +} + +function resetTailscaleTestEnv(fakeBinaryPath: string) { + process.env.TAILSCALE_BIN = fakeBinaryPath; + process.env.TAILSCALED_BIN = fakeBinaryPath; + delete process.env.TAILSCALE_TEST_STATUS_JSON; + delete process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON; + delete process.env.TAILSCALE_TEST_FUNNEL_OUTPUT; + delete process.env.TAILSCALE_TEST_FUNNEL_EXIT_CODE; + delete process.env.TAILSCALE_TEST_LOGIN_OUTPUT; + delete process.env.TAILSCALE_TEST_LOGIN_EXIT_CODE; +} + +test.beforeEach(async () => { + const fakeBinaryPath = await createFakeTailscaleBinary(); + resetTailscaleTestEnv(fakeBinaryPath); + mitmManager.clearCachedPassword(); + dbCore.resetDbInstance(); + await fs.rm(TEST_DATA_DIR, { recursive: true, force: true }); + await fs.mkdir(TEST_DATA_DIR, { recursive: true }); + const recreatedBinaryPath = await createFakeTailscaleBinary(); + resetTailscaleTestEnv(recreatedBinaryPath); +}); + +test.after(async () => { + dbCore.resetDbInstance(); + mitmManager.clearCachedPassword(); + if (originalEnv.tailscaleBin === undefined) delete process.env.TAILSCALE_BIN; + else process.env.TAILSCALE_BIN = originalEnv.tailscaleBin; + if (originalEnv.tailscaledBin === undefined) delete process.env.TAILSCALED_BIN; + else process.env.TAILSCALED_BIN = originalEnv.tailscaledBin; + if (originalEnv.statusJson === undefined) delete process.env.TAILSCALE_TEST_STATUS_JSON; + else process.env.TAILSCALE_TEST_STATUS_JSON = originalEnv.statusJson; + if (originalEnv.funnelStatusJson === undefined) + delete process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON; + else process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON = originalEnv.funnelStatusJson; + if (originalEnv.funnelOutput === undefined) delete process.env.TAILSCALE_TEST_FUNNEL_OUTPUT; + else process.env.TAILSCALE_TEST_FUNNEL_OUTPUT = originalEnv.funnelOutput; + if (originalEnv.funnelExitCode === undefined) delete process.env.TAILSCALE_TEST_FUNNEL_EXIT_CODE; + else process.env.TAILSCALE_TEST_FUNNEL_EXIT_CODE = originalEnv.funnelExitCode; + if (originalEnv.loginOutput === undefined) delete process.env.TAILSCALE_TEST_LOGIN_OUTPUT; + else process.env.TAILSCALE_TEST_LOGIN_OUTPUT = originalEnv.loginOutput; + if (originalEnv.loginExitCode === undefined) delete process.env.TAILSCALE_TEST_LOGIN_EXIT_CODE; + else process.env.TAILSCALE_TEST_LOGIN_EXIT_CODE = originalEnv.loginExitCode; + await fs.rm(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("extractTailscaleAuthUrl and extractTailscaleEnableUrl parse login URLs", () => { + assert.equal( + tailscaleTunnel.extractTailscaleAuthUrl( + "To authenticate, visit https://login.tailscale.com/a/demo-token in your browser." + ), + "https://login.tailscale.com/a/demo-token" + ); + assert.equal( + tailscaleTunnel.extractTailscaleEnableUrl( + "Funnel is not enabled yet. Open https://login.tailscale.com/f/funnel-demo to continue." + ), + "https://login.tailscale.com/f/funnel-demo" + ); +}); + +test("getTailscaleUrlFromStatusPayload normalizes DNS names into HTTPS URLs", () => { + assert.equal( + tailscaleTunnel.getTailscaleUrlFromStatusPayload({ + Self: { DNSName: "omniroute-demo.tail123.ts.net." }, + }), + "https://omniroute-demo.tail123.ts.net" + ); +}); + +test("getTailscaleTunnelStatus reflects a logged-in running funnel", async () => { + process.env.TAILSCALE_TEST_STATUS_JSON = JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "omniroute-demo.tail123.ts.net." }, + }); + process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON = JSON.stringify({ + AllowFunnel: { + "443": true, + }, + }); + + const status = await tailscaleTunnel.getTailscaleTunnelStatus(); + + assert.equal(status.installed, true); + assert.equal(status.loggedIn, true); + assert.equal(status.running, true); + assert.equal(status.phase, "running"); + assert.equal(status.tunnelUrl, "https://omniroute-demo.tail123.ts.net"); + assert.equal(status.apiUrl, "https://omniroute-demo.tail123.ts.net/v1"); +}); + +test("enableTailscaleTunnel returns an auth URL when login is still required", async () => { + process.env.TAILSCALE_TEST_STATUS_JSON = JSON.stringify({ + BackendState: "NeedsLogin", + Self: { DNSName: "omniroute-demo.tail123.ts.net." }, + }); + process.env.TAILSCALE_TEST_LOGIN_OUTPUT = + "Authenticate at https://login.tailscale.com/a/login-token"; + + const result = await tailscaleTunnel.enableTailscaleTunnel(); + + assert.equal(result.success, false); + assert.equal("needsLogin" in result, true); + if ("needsLogin" in result) { + assert.equal(result.authUrl, "https://login.tailscale.com/a/login-token"); + } +}); + +test("enableTailscaleTunnel returns the funnel enable URL when the tailnet has Funnel disabled", async () => { + process.env.TAILSCALE_TEST_STATUS_JSON = JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "omniroute-demo.tail123.ts.net." }, + }); + process.env.TAILSCALE_TEST_FUNNEL_OUTPUT = + "Funnel is not enabled. Continue at https://login.tailscale.com/f/funnel-token"; + + const result = await tailscaleTunnel.enableTailscaleTunnel(); + + assert.equal(result.success, false); + assert.equal("funnelNotEnabled" in result, true); + if ("funnelNotEnabled" in result) { + assert.equal(result.enableUrl, "https://login.tailscale.com/f/funnel-token"); + } +}); + +test("enableTailscaleTunnel stores the funnel URL and disableTailscaleTunnel clears it", async () => { + process.env.TAILSCALE_TEST_STATUS_JSON = JSON.stringify({ + BackendState: "Running", + Self: { DNSName: "omniroute-demo.tail123.ts.net." }, + }); + process.env.TAILSCALE_TEST_FUNNEL_STATUS_JSON = JSON.stringify({ + AllowFunnel: { + "443": true, + }, + }); + process.env.TAILSCALE_TEST_FUNNEL_OUTPUT = "Available at https://omniroute-demo.tail123.ts.net"; + + const enabled = await tailscaleTunnel.enableTailscaleTunnel(); + const settingsAfterEnable = await settingsDb.getSettings(); + + assert.equal(enabled.success, true); + if (enabled.success) { + assert.equal(enabled.tunnelUrl, "https://omniroute-demo.tail123.ts.net"); + } + assert.equal(settingsAfterEnable.tailscaleEnabled, true); + assert.equal(settingsAfterEnable.tailscaleUrl, "https://omniroute-demo.tail123.ts.net"); + + const disabled = await tailscaleTunnel.disableTailscaleTunnel(); + const settingsAfterDisable = await settingsDb.getSettings(); + + assert.equal(disabled.success, true); + assert.equal(settingsAfterDisable.tailscaleEnabled, false); + assert.equal(settingsAfterDisable.tailscaleUrl, ""); +}); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index e16539c375..e483719547 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -160,6 +160,36 @@ test("Gemini non-stream: restores sanitized tool names from the request map", () assert.equal((result as any).choices[0].message.tool_calls[0].function.name, originalToolName); }); +test("Gemini non-stream: restores Antigravity _ide-cloaked tool names from the request map", () => { + const result = translateNonStreamingResponse( + { + responseId: "resp-ag-tool-map", + modelVersion: "antigravity/gemini-2.5-pro", + createTime: "2026-04-22T12:00:00.000Z", + candidates: [ + { + content: { + parts: [ + { + functionCall: { + name: "read_project_file_ide", + args: { path: "/tmp/a" }, + }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + FORMATS.ANTIGRAVITY, + FORMATS.OPENAI, + new Map([["read_project_file_ide", "read_project_file"]]) + ); + + assert.equal((result as any).choices[0].message.tool_calls[0].function.name, "read_project_file"); +}); + test("Gemini stream: first text chunk emits assistant role then content delta", () => { const state = createStreamingState(); const result = geminiToOpenAIResponse(