mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Integrated into release/v3.7.0
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
182
open-sse/config/errorConfig.ts
Normal file
182
open-sse/config/errorConfig.ts
Normal file
@@ -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<number, ErrorInfo> = {
|
||||
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<number, string> = {
|
||||
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);
|
||||
}
|
||||
@@ -1046,6 +1046,53 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
],
|
||||
},
|
||||
|
||||
"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",
|
||||
|
||||
208
open-sse/config/toolCloaking.ts
Normal file
208
open-sse/config/toolCloaking.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<string>(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<T extends JsonRecord>(
|
||||
body: T
|
||||
): {
|
||||
body: T;
|
||||
toolNameMap: Map<string, string> | null;
|
||||
} {
|
||||
const request = asRecord(body.request);
|
||||
if (!request) {
|
||||
return { body, toolNameMap: null };
|
||||
}
|
||||
|
||||
const existingToolNameMap =
|
||||
body._toolNameMap instanceof Map ? (body._toolNameMap as Map<string, string>) : null;
|
||||
const toolNameMap = existingToolNameMap
|
||||
? new Map(existingToolNameMap)
|
||||
: new Map<string, string>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<T>(payload: T, toolNameMap: Map<string, string> | 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<string, string> | null = null;
|
||||
|
||||
if (transformedBody instanceof Response) {
|
||||
return { response: transformedBody, url, headers, transformedBody: body };
|
||||
}
|
||||
|
||||
if (transformedBody && typeof transformedBody === "object") {
|
||||
const cloaked = cloakAntigravityToolPayload(transformedBody as Record<string, unknown>);
|
||||
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) {
|
||||
|
||||
458
open-sse/executors/blackbox-web.ts
Normal file
458
open-sse/executors/blackbox-web.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<Record<string, unknown>>,
|
||||
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<Uint8Array>,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<string> {
|
||||
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<Uint8Array> {
|
||||
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<string, unknown>).messages as
|
||||
| Array<Record<string, unknown>>
|
||||
| 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<string, string> = {
|
||||
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<string, unknown>).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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
778
open-sse/executors/muse-spark-web.ts
Normal file
778
open-sse/executors/muse-spark-web.ts
Normal file
@@ -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<string, MuseSparkModelInfo> = {
|
||||
"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<string, unknown> {
|
||||
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<Record<string, unknown>>): 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<Record<string, unknown>> {
|
||||
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<string, unknown> => !!frame);
|
||||
}
|
||||
|
||||
function collectRendererTexts(value: unknown, seen: Set<string>, 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, unknown>): 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<string, unknown>) {
|
||||
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<Uint8Array> {
|
||||
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<Uint8Array>,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<string> {
|
||||
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<string, unknown>).messages as
|
||||
| Array<Record<string, unknown>>
|
||||
| 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<string, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<string, string> | null,
|
||||
transformedBody: Record<string, unknown> | null | undefined
|
||||
) {
|
||||
const executorToolNameMap =
|
||||
transformedBody && transformedBody._toolNameMap instanceof Map
|
||||
? (transformedBody._toolNameMap as Map<string, string>)
|
||||
: 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<T extends Record<string, unknown>>(result: T): T {
|
||||
const snapshot =
|
||||
result && typeof result === "object"
|
||||
@@ -2501,8 +2545,13 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
const responseToolNameMap = mergeResponseToolNameMap(
|
||||
toolNameMap,
|
||||
(finalBody as Record<string, unknown> | 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<string, string> | null
|
||||
responseToolNameMap as Map<string, string> | 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<string, unknown> | 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
106
package-lock.json
generated
106
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<CloudflaredTunnelStatus | null>(null);
|
||||
const [cloudflaredBusy, setCloudflaredBusy] = useState(false);
|
||||
const [cloudflaredNotice, setCloudflaredNotice] = useState<TunnelNotice | null>(null);
|
||||
const [tailscaleStatus, setTailscaleStatus] = useState<TailscaleTunnelStatus | null>(null);
|
||||
const [tailscaleBusy, setTailscaleBusy] = useState(false);
|
||||
const [tailscaleNotice, setTailscaleNotice] = useState<TunnelNotice | null>(null);
|
||||
const [showTailscaleInstallModal, setShowTailscaleInstallModal] = useState(false);
|
||||
const [tailscaleInstallBusy, setTailscaleInstallBusy] = useState(false);
|
||||
const [tailscaleInstallLog, setTailscaleInstallLog] = useState<string[]>([]);
|
||||
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<string, unknown> = {}) => {
|
||||
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<string, unknown> | 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<TailscaleTunnelPhase, { label: string; className: string }> = {
|
||||
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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -676,6 +1057,120 @@ export default function APIPageClient({ machineId }) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-border/70 bg-surface/40 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{translateOrFallback("tailscaleTitle", "Tailscale Funnel")}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${tailscalePhaseMeta[tailscalePhase].className}`}
|
||||
>
|
||||
{tailscalePhaseMeta[tailscalePhase].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tailscaleStatus?.supported !== false && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tailscaleStatus?.running ? "secondary" : "primary"}
|
||||
icon={tailscaleStatus?.running ? "vpn_lock_off" : "vpn_lock"}
|
||||
onClick={() => {
|
||||
if (tailscaleStatus?.running) {
|
||||
void handleTailscaleDisable();
|
||||
} else if (!tailscaleStatus?.installed) {
|
||||
setShowTailscaleInstallModal(true);
|
||||
} else {
|
||||
void handleTailscaleEnable();
|
||||
}
|
||||
}}
|
||||
loading={tailscaleBusy}
|
||||
className={
|
||||
tailscaleStatus?.running
|
||||
? "border-border/70! text-text-muted! hover:text-text!"
|
||||
: "bg-linear-to-r from-indigo-500 to-cyan-500 hover:from-indigo-600 hover:to-cyan-600"
|
||||
}
|
||||
>
|
||||
{tailscaleActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tailscaleNotice && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||
tailscaleNotice.type === "success"
|
||||
? "border-green-500/30 bg-green-500/10 text-green-400"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
|
||||
: "border-red-500/30 bg-red-500/10 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{tailscaleNotice.type === "success"
|
||||
? "check_circle"
|
||||
: tailscaleNotice.type === "info"
|
||||
? "info"
|
||||
: "error"}
|
||||
</span>
|
||||
<span className="flex-1">{tailscaleNotice.message}</span>
|
||||
<button
|
||||
onClick={() => setTailscaleNotice(null)}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">{tailscaleUrlNotice}</p>
|
||||
{tailscaleStatus?.phase === "needs_login" && (
|
||||
<p className="text-xs text-blue-400">
|
||||
{translateOrFallback(
|
||||
"tailscaleNeedsLoginHint",
|
||||
"Authenticate this machine with Tailscale, then enable Funnel."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
value={tailscaleStatus?.apiUrl || ""}
|
||||
readOnly
|
||||
placeholder="https://your-device.tailnet.ts.net/v1"
|
||||
className="flex-1 min-w-0 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={copied === "tailscale_url" ? "check" : "content_copy"}
|
||||
onClick={() =>
|
||||
tailscaleStatus?.apiUrl && copy(tailscaleStatus.apiUrl, "tailscale_url")
|
||||
}
|
||||
disabled={!tailscaleStatus?.apiUrl}
|
||||
className="shrink-0 self-start sm:self-auto"
|
||||
>
|
||||
{copied === "tailscale_url" ? tc("copied") : tc("copy")}
|
||||
</Button>
|
||||
</div>
|
||||
{tailscaleStatus?.binaryPath && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{translateOrFallback("tailscaleBinaryPath", "Binary: {path}", {
|
||||
path: tailscaleStatus.binaryPath,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{tailscaleStatus?.lastError && (
|
||||
<p className="text-xs text-red-400">
|
||||
{translateOrFallback("tailscaleLastError", "Last error: {error}", {
|
||||
error: tailscaleStatus.lastError,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
@@ -1320,6 +1815,72 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showTailscaleInstallModal}
|
||||
title={translateOrFallback("tailscaleInstallTitle", "Install Tailscale")}
|
||||
onClose={() => !tailscaleInstallBusy && setShowTailscaleInstallModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<p className="text-sm font-medium text-blue-300">
|
||||
{translateOrFallback(
|
||||
"tailscaleInstallIntro",
|
||||
"Installs Tailscale on this machine and prepares OmniRoute to enable Funnel."
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-blue-200/80">
|
||||
{translateOrFallback(
|
||||
"tailscaleInstallPasswordHint",
|
||||
"On macOS and Linux, sudo may be required for the package install and daemon start."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
value={tailscalePassword}
|
||||
onChange={(event) => setTailscalePassword(event.target.value)}
|
||||
placeholder={translateOrFallback("tailscaleSudoPlaceholder", "Optional sudo password")}
|
||||
disabled={tailscaleInstallBusy}
|
||||
/>
|
||||
|
||||
{tailscaleInstallLog.length > 0 && (
|
||||
<div className="max-h-48 overflow-auto rounded-lg border border-border/70 bg-surface/60 p-3">
|
||||
<pre className="whitespace-pre-wrap text-xs text-text-muted">
|
||||
{tailscaleInstallLog.join("\n")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => void handleTailscaleInstall()}
|
||||
fullWidth
|
||||
disabled={tailscaleInstallBusy}
|
||||
>
|
||||
{tailscaleInstallBusy ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">
|
||||
progress_activity
|
||||
</span>
|
||||
{translateOrFallback("tailscaleInstalling", "Installing")}
|
||||
</span>
|
||||
) : (
|
||||
translateOrFallback("tailscaleInstallAndEnable", "Install & Enable")
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowTailscaleInstallModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={tailscaleInstallBusy}
|
||||
>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* Provider Models Popup */}
|
||||
{selectedProvider && (
|
||||
<ProviderModelsModal
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults,
|
||||
getCodexRequestDefaults as _getCodexRequestDefaults,
|
||||
} from "@/lib/providers/requestDefaults";
|
||||
import { isClaudeExtraUsageBlockEnabled } from "@/lib/providers/claudeExtraUsage";
|
||||
import { resolveDashboardProviderInfo } from "../providerPageUtils";
|
||||
|
||||
type CompatByProtocolMap = Partial<
|
||||
@@ -492,6 +493,7 @@ interface ConnectionRowConnection {
|
||||
interface ConnectionRowProps {
|
||||
connection: ConnectionRowConnection;
|
||||
isOAuth: boolean;
|
||||
isClaude?: boolean;
|
||||
isCodex?: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
@@ -499,6 +501,7 @@ interface ConnectionRowProps {
|
||||
onMoveDown: () => void;
|
||||
onToggleActive: (isActive?: boolean) => void | Promise<void>;
|
||||
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({
|
||||
<span className="material-symbols-outlined text-[13px]">shield</span>
|
||||
{rateLimitEnabled ? t("rateLimitProtected") : t("rateLimitUnprotected")}
|
||||
</button>
|
||||
{isClaude && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
<button
|
||||
onClick={() => onToggleClaudeExtraUsage?.(!claudeBlockExtraUsageEnabled)}
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium transition-all cursor-pointer ${
|
||||
claudeBlockExtraUsageEnabled
|
||||
? "bg-amber-500/15 text-amber-500 hover:bg-amber-500/25"
|
||||
: "bg-black/[0.03] dark:bg-white/[0.03] text-text-muted/50 hover:text-text-muted hover:bg-black/[0.06] dark:hover:bg-white/[0.06]"
|
||||
}`}
|
||||
title="Toggle Claude extra-usage blocking"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">payments</span>
|
||||
Block Extra {claudeBlockExtraUsageEnabled ? "ON" : "OFF"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isCcCompatible && (
|
||||
<>
|
||||
<span className="text-text-muted/30 select-none">|</span>
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
<div className="pt-6">
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isClaude && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Toggle
|
||||
checked={formData.blockExtraUsage}
|
||||
onChange={(checked) => 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."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCcCompatible && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Toggle
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
getClientVisibleAntigravityModelName,
|
||||
toClientAntigravityModelId,
|
||||
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
|
||||
import {
|
||||
getCachedDiscoveredModels,
|
||||
isAutoFetchModelsEnabled,
|
||||
persistDiscoveredModels,
|
||||
} from "@/lib/providerModels/modelDiscovery";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
22
src/app/api/tunnels/tailscale/check/route.ts
Normal file
22
src/app/api/tunnels/tailscale/check/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/tunnels/tailscale/disable/route.ts
Normal file
25
src/app/api/tunnels/tailscale/disable/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
src/app/api/tunnels/tailscale/enable/route.ts
Normal file
25
src/app/api/tunnels/tailscale/enable/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
51
src/app/api/tunnels/tailscale/install/route.ts
Normal file
51
src/app/api/tunnels/tailscale/install/route.ts
Normal file
@@ -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<string, unknown>) => {
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
25
src/app/api/tunnels/tailscale/login/route.ts
Normal file
25
src/app/api/tunnels/tailscale/login/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/app/api/tunnels/tailscale/route.ts
Normal file
22
src/app/api/tunnels/tailscale/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/app/api/tunnels/tailscale/routeUtils.ts
Normal file
41
src/app/api/tunnels/tailscale/routeUtils.ts
Normal file
@@ -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<T extends z.ZodTypeAny>(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 };
|
||||
}
|
||||
28
src/app/api/tunnels/tailscale/start-daemon/route.ts
Normal file
28
src/app/api/tunnels/tailscale/start-daemon/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SyncedAvailableModel[]> {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
cloudEnabled: false,
|
||||
tailscaleEnabled: false,
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
requestRetry: 3,
|
||||
maxRetryIntervalSec: 30,
|
||||
|
||||
83
src/lib/providerModels/modelDiscovery.ts
Normal file
83
src/lib/providerModels/modelDiscovery.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
getSyncedAvailableModelsForConnection,
|
||||
replaceSyncedAvailableModelsForConnection,
|
||||
type SyncedAvailableModel,
|
||||
} from "@/lib/db/models";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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<string, SyncedAvailableModel>();
|
||||
|
||||
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<SyncedAvailableModel[]> {
|
||||
return getSyncedAvailableModelsForConnection(providerId, connectionId);
|
||||
}
|
||||
|
||||
export async function persistDiscoveredModels(
|
||||
providerId: string,
|
||||
connectionId: string,
|
||||
models: unknown
|
||||
): Promise<SyncedAvailableModel[]> {
|
||||
const normalized = normalizeDiscoveredModels(models);
|
||||
if (normalized.length === 0) return [];
|
||||
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
160
src/lib/providers/claudeExtraUsage.ts
Normal file
160
src/lib/providers/claudeExtraUsage.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
src/lib/providers/webCookieAuth.ts
Normal file
18
src/lib/providers/webCookieAuth.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
1105
src/lib/tailscaleTunnel.ts
Normal file
1105
src/lib/tailscaleTunnel.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<ProviderConnectionLike> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
90
tests/unit/antigravity-tool-cloaking.test.ts
Normal file
90
tests/unit/antigravity-tool-cloaking.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
271
tests/unit/blackbox-web.test.ts
Normal file
271
tests/unit/blackbox-web.test.ts
Normal file
@@ -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<string, string> = {};
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
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"));
|
||||
});
|
||||
94
tests/unit/claude-extra-usage.test.ts
Normal file
94
tests/unit/claude-extra-usage.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
48
tests/unit/error-config.test.ts
Normal file
48
tests/unit/error-config.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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" }],
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
327
tests/unit/muse-spark-web.test.ts
Normal file
327
tests/unit/muse-spark-web.test.ts
Normal file
@@ -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<Record<string, unknown>>) {
|
||||
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<string, string> = {};
|
||||
let capturedBody: Record<string, unknown> = {};
|
||||
|
||||
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"));
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, string>)?.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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
221
tests/unit/tailscaleTunnel.test.ts
Normal file
221
tests/unit/tailscaleTunnel.test.ts
Normal file
@@ -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, "");
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user