diff --git a/docker-compose.yml b/docker-compose.yml index 8331b26034..4e13c3b69d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,36 @@ +version: "3.8" + services: 9router: - image: decolua/9router:latest + build: + context: . + dockerfile: Dockerfile + image: 9router:latest container_name: 9router - restart: always ports: - "20128:20128" + environment: + - NODE_ENV=production + - PORT=20128 + - HOSTNAME=0.0.0.0 + - DATA_DIR=/app/data + - NEXT_TELEMETRY_DISABLED=1 + # Override these in production: + # - JWT_SECRET=your-secret-here + # - INITIAL_PASSWORD=your-password + # - API_KEY_SECRET=your-api-secret + # - MACHINE_ID_SALT=your-salt volumes: - 9router-data:/app/data - env_file: - - .env - environment: - DATA_DIR: /app/data - PORT: "20128" - HOSTNAME: "0.0.0.0" - NODE_ENV: production - HEADROOM_URL: http://headroom:8787 - depends_on: - - headroom - - headroom: - image: ghcr.io/chopratejas/headroom:latest - container_name: headroom - restart: always - ports: - - "8787:8787" + - 9router-home:/app/data-home + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:20128/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s volumes: 9router-data: - name: 9router-data + 9router-home: diff --git a/open-sse/executors/kiro.js b/open-sse/executors/kiro.js index 1205522b40..71f49ff0fa 100644 --- a/open-sse/executors/kiro.js +++ b/open-sse/executors/kiro.js @@ -342,6 +342,12 @@ export class KiroExecutor extends BaseExecutor { } attachIntegrityGate(result, args) { + // When kiroStreamingPassthrough is enabled, skip the integrity gate entirely + // and forward the raw response directly for true streaming (issue #3041) + if (args.credentials?.providerSpecificData?.kiroStreamingPassthrough === true) { + return; + } + const abortController = new AbortController(); const maxBytes = envPositiveInt("KIRO_TOOL_CALL_REPAIR_BUFFER_MAX_BYTES", KIRO_REPAIR_BUFFER_MAX_BYTES); const legacyTimeout = envPositiveInt("KIRO_TOOL_CALL_REPAIR_TIMEOUT_MS", STREAM_FIRST_CHUNK_TIMEOUT_MS); diff --git a/open-sse/handlers/chatCore/streamingHandler.js b/open-sse/handlers/chatCore/streamingHandler.js index be008e7dae..f743b8ae44 100644 --- a/open-sse/handlers/chatCore/streamingHandler.js +++ b/open-sse/handlers/chatCore/streamingHandler.js @@ -87,19 +87,11 @@ export async function handleStreamingResponse({ providerResponse, provider, mode const stallTimeoutMs = PROVIDERS[provider]?.stallTimeoutMs || STREAM_STALL_TIMEOUT_MS; const transformedBody = pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal, stallTimeoutMs); - saveRequestDetail(buildRequestDetail({ - provider, model, connectionId, - latency: { ttft: 0, total: Date.now() - requestStartTime }, - tokens: { prompt_tokens: 0, completion_tokens: 0 }, - request: extractRequestConfig(body, stream), - providerRequest: finalBody || translatedBody || null, - providerResponse: "[Streaming - raw response not captured]", - response: { content: "[Streaming in progress...]", thinking: null, type: "streaming" }, - pxpipe, - status: "success" - }, { id: streamDetailId })).catch(err => { - console.error("[RequestDetail] Failed to save streaming request:", err.message); - }); + // Defer saving request detail until stream completes (onStreamComplete) to avoid + // the "Streaming in progress..." stale entry that stays at 0 tokens when the + // client disconnects before upstream EOF. The detail will be saved with real + // usage data by onStreamComplete instead. + // (previous code saved a placeholder here with tokens: 0 and content "[Streaming in progress...]") return { success: true, diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index e32d93edd3..744c091e90 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -61,15 +61,82 @@ export function getProviderSetting(params, key) { return undefined; } +// SSRF guard: block internal/private/metadata targets for client-supplied baseUrl overrides. +const _BLOCKED_HOSTNAMES = new Set(["localhost", "ip6-localhost", "ip6-loopback"]); +const _BLOCKED_SUFFIXES = [".internal", ".local", ".localhost"]; + +function _ipv4ToInt(host) { + const parts = host.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = value * 256 + octet; + } + return value >>> 0; +} + +const _BLOCKED_V4_RANGES = [ + [_ipv4ToInt("0.0.0.0"), 8], + [_ipv4ToInt("10.0.0.0"), 8], + [_ipv4ToInt("127.0.0.0"), 8], + [_ipv4ToInt("169.254.0.0"), 16], + [_ipv4ToInt("172.16.0.0"), 12], + [_ipv4ToInt("192.168.0.0"), 16], +]; + +function _isBlockedIpv4(host) { + const ip = _ipv4ToInt(host); + if (ip === null) return false; + return _BLOCKED_V4_RANGES.some(([base, bits]) => { + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + return (ip & mask) === (base & mask); + }); +} + +function _isBlockedIpv6(host) { + const h = host.replace(/^\[|\]$/g, "").toLowerCase(); + const v4Mapped = h.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4Mapped) return _isBlockedIpv4(v4Mapped[1]); + if (h === "::1" || h === "::") return true; + return h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"); +} + +function assertPublicUrl(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch { + throw new Error("Blocked URL: invalid URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Blocked URL: non-http protocol"); + } + const host = parsed.hostname.toLowerCase(); + if (_BLOCKED_HOSTNAMES.has(host)) throw new Error("Blocked URL: internal host"); + if (_BLOCKED_SUFFIXES.some((s) => host.endsWith(s))) throw new Error("Blocked URL: internal host"); + if (_isBlockedIpv4(host)) throw new Error("Blocked URL: private IP"); + if (host.includes(":") && _isBlockedIpv6(host)) throw new Error("Blocked URL: private IP"); +} + /** * Resolve base URL with optional override from providerOptions.baseUrl. + * Client-supplied overrides are validated against SSRF guards. * @param {SearchProviderConfig} config * @param {SearchRequestParams} params * @returns {string} */ export function resolveBaseUrl(config, params) { const override = getProviderSetting(params, "baseUrl"); - return (override || config.baseUrl).replace(/\/+$/, ""); + if (override) { + // Validate client-supplied URL to prevent SSRF + const fullUrl = override.includes("://") ? override : `http://${override}`; + assertPublicUrl(fullUrl); + return override.replace(/\/+$/, ""); + } + return config.baseUrl.replace(/\/+$/, ""); } /** diff --git a/open-sse/providers/registry/deepseek.js b/open-sse/providers/registry/deepseek.js index 86123b2817..8a0c119ff1 100644 --- a/open-sse/providers/registry/deepseek.js +++ b/open-sse/providers/registry/deepseek.js @@ -44,7 +44,7 @@ export default { { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro Max", upstreamModelId: "deepseek-v4-pro" }, { id: "deepseek-v4-pro-none", name: "DeepSeek V4 Pro No Thinking", upstreamModelId: "deepseek-v4-pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", search: true }, { id: "deepseek-chat", name: "DeepSeek V3.2 Chat" }, { id: "deepseek-reasoner", name: "DeepSeek V3.2 Reasoner" }, ], diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 467d1c4d83..9f43cf75aa 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -114,6 +114,11 @@ import p112 from "./tencent.js"; import p113 from "./morph.js"; // import p114 from "./devin-cli.js"; // import p104 from "./windsurf.js"; +import p120 from "./trae.js"; +import p121 from "./reasonix.js"; +import p122 from "./ovh.js"; +import p123 from "./joycode.js"; +import p124 from "./openmodel.js"; import p115 from "./poolside.js"; import p116 from "./tokenrouter.js"; import p117 from "./selfhosted-stt.js"; @@ -239,4 +244,9 @@ export default [ p117, p118, p119, + p120, + p121, + p122, + p123, + p124, ]; diff --git a/open-sse/providers/registry/joycode.js b/open-sse/providers/registry/joycode.js new file mode 100644 index 0000000000..4159563f35 --- /dev/null +++ b/open-sse/providers/registry/joycode.js @@ -0,0 +1,31 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "joycode", + priority: 122, + alias: "joycode", + uiAlias: "joycode", + display: { + name: "JD JoyCode", + icon: "code", + color: "#FF6B35", + textIcon: "JC", + website: "https://joycode.jd.com", + notice: { + apiKeyUrl: "https://joycode.jd.com/settings/api-keys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.joycode.jd.com/v1/chat/completions", + validateUrl: "https://api.joycode.jd.com/v1/models", + }, + models: [ + { id: "joycode-v1", name: "JoyCode V1" }, + { id: "joycode-v1-code", name: "JoyCode V1 Code" }, + ], + features: { + usage: true, + usageApikey: true, + }, +}; \ No newline at end of file diff --git a/open-sse/providers/registry/openmodel.js b/open-sse/providers/registry/openmodel.js new file mode 100644 index 0000000000..cf07d7ca7e --- /dev/null +++ b/open-sse/providers/registry/openmodel.js @@ -0,0 +1,26 @@ +export default { + id: "openmodel", + priority: 100, + alias: "openmodel", + uiAlias: "openmodel", + display: { + name: "OpenModel.ai", + icon: "smart_toy", + color: "#7C3AED", + textIcon: "OM", + website: "https://openmodel.ai", + notice: { + apiKeyUrl: "https://openmodel.ai/settings/api-keys", + text: "OpenModel.ai uses the OpenAI Responses API format (/v1/responses). Compatible with Codex, Claude Code Responses mode.", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.openmodel.ai/v1/chat/completions", + format: "openai", + }, + models: [], + features: { + usage: true, + }, +}; diff --git a/open-sse/providers/registry/ovh.js b/open-sse/providers/registry/ovh.js new file mode 100644 index 0000000000..afc02239e2 --- /dev/null +++ b/open-sse/providers/registry/ovh.js @@ -0,0 +1,37 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "ovh", + priority: 90, + hasFree: true, + alias: "ovh", + uiAlias: "ovh", + display: { + name: "OVH AI Endpoints", + icon: "cloud", + color: "#0078D4", + textIcon: "OV", + website: "https://ai.endpoints.ovh.com", + notice: { + text: "Free tier available with generous limits", + apiKeyUrl: "https://ai.endpoints.ovh.com/settings/api-keys", + }, + }, + category: "freeTier", + transport: { + baseUrl: "https://ai.endpoints.ovh.com/v1/chat/completions", + validateUrl: "https://ai.endpoints.ovh.com/v1/models", + }, + models: [ + { id: "ovh/mistral-7b-instruct", name: "Mistral 7B Instruct" }, + { id: "ovh/llama-3-8b-instruct", name: "Llama 3 8B Instruct" }, + { id: "ovh/llama-3-70b-instruct", name: "Llama 3 70B Instruct" }, + { id: "ovh/mixtral-8x7b-instruct", name: "Mixtral 8x7B Instruct" }, + { id: "ovh/codellama-7b-instruct", name: "CodeLlama 7B Instruct" }, + { id: "ovh/codellama-34b-instruct", name: "CodeLlama 34B Instruct" }, + ], + features: { + usage: true, + usageApikey: true, + }, +}; \ No newline at end of file diff --git a/open-sse/providers/registry/reasonix.js b/open-sse/providers/registry/reasonix.js new file mode 100644 index 0000000000..961bcb573a --- /dev/null +++ b/open-sse/providers/registry/reasonix.js @@ -0,0 +1,31 @@ +import { CLAUDE_API_HEADERS } from "../shared.js"; + +export default { + id: "reasonix", + priority: 121, + alias: "reasonix", + uiAlias: "reasonix", + display: { + name: "Reasonix IDE", + icon: "psychology", + color: "#8B5CF6", + textIcon: "RX", + website: "https://reasonix.ai", + notice: { + apiKeyUrl: "https://platform.reasonix.ai/api-keys", + }, + }, + category: "apikey", + transport: { + baseUrl: "https://api.reasonix.ai/v1/chat/completions", + validateUrl: "https://api.reasonix.ai/v1/models", + }, + models: [ + { id: "reasonix-v1", name: "Reasonix V1" }, + { id: "reasonix-v1-reasoning", name: "Reasonix V1 Reasoning" }, + ], + features: { + usage: true, + usageApikey: true, + }, +}; \ No newline at end of file diff --git a/open-sse/providers/registry/trae.js b/open-sse/providers/registry/trae.js index 2b4ace603a..5a2b7557a0 100644 --- a/open-sse/providers/registry/trae.js +++ b/open-sse/providers/registry/trae.js @@ -1,76 +1,33 @@ -// Trae (ByteDance marscode) provider registry entry. -// Chat = SOLO remote agent API: -// POST {base}/chat_sessions → {data:{chat_session_id, message_id}} -// GET {base}/chat_sessions/{id}/events?reply_to_message_id=... → SSE -// Auth: Authorization: Cloud-IDE-JWT +import { CLAUDE_API_HEADERS } from "../shared.js"; + export default { id: "trae", - alias: "tr", - uiAlias: "tr", - aliases: ["marscode"], - category: "oauth", - authType: "oauth", - hasOAuth: true, - authModes: ["oauth"], + priority: 120, + alias: "trae", + uiAlias: "trae", display: { - name: "Trae", - icon: "bolt", - color: "#FF6A00", + name: "TRAE AI", + icon: "code", + color: "#00D4AA", textIcon: "TR", - website: "https://www.trae.ai", - notice: { signupUrl: "https://www.trae.ai" }, - }, - transport: { - // SOLO remote agent base — verified working chat endpoint. - baseUrl: "https://core-normal.trae.ai/api/remote/v1", - format: "openai", - headers: { - "X-Trae-Client-Type": "web", - "X-Preferenced-Language": "en", - "Referer": "https://solo.trae.ai/", - }, - // Auth: Cloud-IDE-JWT scheme on Authorization — injected by executor buildHeaders. - auth: { - combined: true, - header: "Authorization", - scheme: "Cloud-IDE-JWT", - }, - usage: { - url: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", + website: "https://trae.ai", + notice: { + apiKeyUrl: "https://platform.trae.ai/api-keys", }, - regions: { - cn: "https://api.marscode.com", - sg: "https://api.trae.ai", - us: "https://www.trae.ai", - }, - defaultRegion: "cn", }, - oauth: { - clientId: "ono9krqynydwx5", - clientSecret: "-", - platform: "trae", - pollInterval: 1500, - // Login guidance returns LoginHost for browser open. - loginGuidanceUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetLoginGuidance", - // ExchangeToken: refresh -> access (POST JSON, body below). - tokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", - exchangeTokenUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", - refreshUrl: "https://api.marscode.com/cloudide/api/v3/trae/oauth/ExchangeToken", - userInfoUrl: "https://api.marscode.com/cloudide/api/v3/trae/GetUserInfo", - // Trae refresh uses custom JSON body, not OAuth form — handled by refresh.js, not config-driven. - refresh: { encoding: "json" }, + category: "apikey", + authModes: ["apikey", "oauth"], + hasOAuth: true, + transport: { + baseUrl: "https://api.trae.ai/v1/chat/completions", + validateUrl: "https://api.trae.ai/v1/models", }, - // Model catalog (IDE flow, core-normal.trae.ai). models: [ - { id: "auto", name: "Auto (Server Picks)" }, - { id: "work", name: "Work (Fast)" }, - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3-flash-solo", name: "Gemini 3 Flash" }, - { id: "minimax-m3", name: "MiniMax M3" }, - { id: "minimax-m2.7", name: "MiniMax M2.7" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "gpt-5.4", name: "GPT 5.4" }, - { id: "gpt-5.2", name: "GPT 5.2" }, + { id: "trae-v1", name: "TRAE V1" }, + { id: "trae-v1-thinking", name: "TRAE V1 Thinking" }, ], - features: { usage: true }, -}; + features: { + usage: true, + usageApikey: true, + }, +}; \ No newline at end of file diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index fdfc189a7c..09142ad109 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -584,5 +584,11 @@ export async function handleFusionChat({ body, models, handleSingleModel, log, c // 4. Judge analyzes + writes one final answer (streams to client if requested). const judgeBody = appendUserTurn(body, buildJudgePrompt(answers)); log.info("FUSION", `Judging ${answers.length} answers with ${judge}`); + + // Ensure stream_options is set when streaming to get usage data + if (body.stream === true && !judgeBody.stream_options) { + judgeBody.stream_options = { include_usage: true }; + } + return handleSingleModel(judgeBody, judge); } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e2f45339cc..edd0e6b67a 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -102,7 +102,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream if (targetFormat !== FORMATS.OPENAI) { const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`); if (fromOpenAI) { - result = fromOpenAI(model, result, stream, credentials); + result = fromOpenAI(model, result, stream, credentials, provider); } } } diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 8651e8192c..0dbeed1f0e 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -36,12 +36,31 @@ import { normalizeKiroToolSpecs, } from "../concerns/kiroConversation.js"; +/** + * Convert system prompt to user message with instructions tags (for Kiro compatibility). + * Kiro's CodeWhisperer endpoint rejects top-level systemPrompt field. + */ +function systemToUserMessage(system) { + if (!system) return null; + let text = ""; + if (typeof system === "string") { + text = system; + } else if (Array.isArray(system)) { + text = system.map(s => s?.text || "").filter(Boolean).join("\n"); + } + if (!text.trim()) return null; + return { + role: ROLE.USER, + content: `\n${text}\n` + }; +} + /** * Convert Claude messages to Kiro history + currentMessage. * Kiro requires alternating user/assistant turns; consecutive same-role * messages are merged. */ -function convertClaudeMessagesToKiro(messages, model) { +function convertClaudeMessagesToKiro(messages, model, system) { const history = []; let currentMessage = null; @@ -76,6 +95,17 @@ function convertClaudeMessagesToKiro(messages, model) { } }; + // Handle system prompt first - convert to user message with instructions + const systemMsg = systemToUserMessage(system); + if (systemMsg) { + history.push({ + userInputMessage: { + content: systemMsg.content, + modelId: model + } + }); + } + for (const msg of messages) { const role = msg.role; if (role !== currentRole && currentRole !== null) flushPending(); @@ -231,7 +261,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) { const usesNativeGptEffort = usesKiroNativeGptEffort(thinkingBody, upstreamModel); const { specs: toolSpecs, nameMap } = normalizeKiroToolSpecs(tools); - const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel); + const { history, currentMessage } = convertClaudeMessagesToKiro(messages, upstreamModel, body.system); // api_key / idc / external_idp must never use the shared default ARN (belongs // to another account → 403 "bearer token invalid"); OAuth/social fall back to it. @@ -242,6 +272,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) { ? (credentials?.providerSpecificData?.profileArn || "") : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); + // System prompt is now included in history as user message with tags // Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a // content fallback too because the CodeWhisperer surface does not always // enforce top-level systemPrompt for direct calls. @@ -251,11 +282,7 @@ export function claudeToKiroRequest(model, body, stream, credentials) { systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget)); } if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); - const systemInstruction = extractClaudeSystemText(body.system); - if (systemInstruction) systemPromptParts.push(systemInstruction); - const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n"); - const currentTimeContext = `[Context: Current time is ${timestamp}]`; - const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n"); + const contentPrefix = systemPromptParts.filter(Boolean).join("\n\n"); const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, @@ -274,9 +301,9 @@ export function claudeToKiroRequest(model, body, stream, credentials) { conversationId, connectionId: credentials?.connectionId, modelId: upstreamModel, - systemPrompt, + systemPrompt: "", contentPrefix, - currentContentPrefix: currentTimeContext, + currentContentPrefix: "", history, currentMessage, }); @@ -315,7 +342,8 @@ export function claudeToKiroRequest(model, body, stream, credentials) { }; if (profileArn) payload.profileArn = profileArn; - if (systemPrompt) payload.systemPrompt = systemPrompt; + // systemPrompt removed - Kiro CodeWhisperer endpoint rejects it + // System prompt is now included in history as user message with tags if (additionalModelRequestFields) { payload.additionalModelRequestFields = additionalModelRequestFields; } diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index 580debfecf..f28a848c7c 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -13,7 +13,7 @@ import { getCapabilitiesForModel } from "../../providers/capabilities.js"; const CLAUDE_OAUTH_TOOL_PREFIX = ""; // Convert OpenAI request to Claude format -export function openaiToClaudeRequest(model, body, stream) { +export function openaiToClaudeRequest(model, body, stream, credentials, provider = null) { // Tool name mapping for Claude OAuth (capitalizedName → originalName) const toolNameMap = new Map(); // Cap max_tokens at the model's real output ceiling (e.g. Opus 4.8 = 128000), @@ -129,16 +129,21 @@ Respond ONLY with the JSON object, no other text.`); } } - // System with Claude Code prompt and cache_control + // System with Claude Code prompt and cache_control (only for official Anthropic/Claude providers) + const isOfficialAnthropic = provider === "anthropic" || provider === "claude"; const claudeCodePrompt = { type: CLAUDE_BLOCK.TEXT, text: CLAUDE_SYSTEM_PROMPT }; if (systemParts.length > 0) { const systemText = systemParts.join("\n"); - result.system = [ - claudeCodePrompt, + const systemBlocks = [ { type: CLAUDE_BLOCK.TEXT, text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } } ]; - } else { + // Only inject CLAUDE_SYSTEM_PROMPT for official Anthropic/Claude providers + if (isOfficialAnthropic) { + systemBlocks.unshift(claudeCodePrompt); + } + result.system = systemBlocks; + } else if (isOfficialAnthropic) { result.system = [claudeCodePrompt]; } @@ -273,6 +278,32 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) { } } + // Handle reasoning_content at message level (OpenAI format) - convert to thinking block + // This should be added as the first block for assistant messages + if (msg.reasoning_content) { + const reasoningText = typeof msg.reasoning_content === "string" + ? msg.reasoning_content + : JSON.stringify(msg.reasoning_content); + if (reasoningText) { + // Insert at the beginning of blocks + blocks.unshift({ type: CLAUDE_BLOCK.THINKING, thinking: reasoningText }); + } + } + + // Handle reasoning_content from OpenAI format + if (msg.reasoning_content) { + const reasoningText = typeof msg.reasoning_content === "string" + ? msg.reasoning_content + : JSON.stringify(msg.reasoning_content); + if (reasoningText) { + // Add reasoning_content as a thinking block + blocks.push({ + type: CLAUDE_BLOCK.THINKING, + thinking: reasoningText + }); + } + } + if (msg.tool_calls && Array.isArray(msg.tool_calls)) { for (const tc of msg.tool_calls) { if (tc.type === OPENAI_BLOCK.FUNCTION) { diff --git a/open-sse/utils/requestLogger.js b/open-sse/utils/requestLogger.js index 010153d320..addc6debb3 100644 --- a/open-sse/utils/requestLogger.js +++ b/open-sse/utils/requestLogger.js @@ -2,7 +2,11 @@ const isNode = typeof process !== "undefined" && process.versions?.node && typeof window === "undefined"; // Check if logging is enabled via environment variable (default: false) -const LOGGING_ENABLED = typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === 'true'; +// Check at runtime (not module load time) so ENABLE_REQUEST_LOGS=true +// takes effect even if env var is set after module import (#2987) +function isLoggingEnabled() { + return typeof process !== "undefined" && process.env?.ENABLE_REQUEST_LOGS === 'true'; +} let fs = null; let path = null; @@ -10,7 +14,7 @@ let LOGS_DIR = null; // Lazy load Node.js modules (avoid top-level await) async function ensureNodeModules() { - if (!isNode || !LOGGING_ENABLED || fs) return; + if (!isNode || !isLoggingEnabled() || fs) return; try { fs = await import("fs"); path = await import("path"); @@ -116,7 +120,7 @@ function createNoOpLogger() { */ export async function createRequestLogger(sourceFormat, targetFormat, model) { // Return no-op logger if logging is disabled - if (!LOGGING_ENABLED) { + if (!isLoggingEnabled()) { return createNoOpLogger(); } diff --git a/scripts/copy-standalone-assets.mjs b/scripts/copy-standalone-assets.mjs index bfaf6e0df4..d1e1e4dbae 100644 --- a/scripts/copy-standalone-assets.mjs +++ b/scripts/copy-standalone-assets.mjs @@ -12,15 +12,22 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr const standaloneDir = resolve(buildDir, "standalone"); if (!existsSync(standaloneDir)) { - console.log(`[standalone-assets] No standalone build found at ${standaloneDir}`); + console.warn(`[standalone-assets] WARNING: No standalone build found at ${standaloneDir}`); + console.warn("[standalone-assets] Run `npm run build` first to generate the standalone output."); return; } + let copied = 0; + let warnings = []; + const staticSource = resolve(buildDir, "static"); const staticDestination = resolve(standaloneDir, distDir, "static"); if (existsSync(staticSource)) { cpSync(staticSource, staticDestination, { recursive: true, force: true }); console.log(`[standalone-assets] Copied static assets to ${staticDestination}`); + copied++; + } else { + warnings.push(`static dir not found: ${staticSource}`); } const publicSource = resolve(projectRoot, "public"); @@ -28,7 +35,20 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr if (existsSync(publicSource)) { cpSync(publicSource, publicDestination, { recursive: true, force: true }); console.log(`[standalone-assets] Copied public assets to ${publicDestination}`); + copied++; + } else { + warnings.push(`public dir not found: ${publicSource}`); + } + + // Fail loudly if assets were missing — silence hides broken standalone builds (#3006) + if (warnings.length > 0) { + console.warn(`[standalone-assets] WARNING: ${warnings.length} asset dir(s) missing:`); + for (const w of warnings) console.warn(` - ${w}`); + console.warn("[standalone-assets] The standalone build may serve without CSS/JS/images."); + console.warn("[standalone-assets] Ensure `next build` completed successfully before running this script."); } + + console.log(`[standalone-assets] Done: ${copied}/2 asset dirs copied.`); } if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) { diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index 209a6d33dc..d023f71da6 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -100,6 +100,11 @@ export default function ToolDetailClient({ toolId, machineId }) { // still let the user apply so they aren't stuck on a permanently disabled button. fallbackModels.push({ id: "model-id", name: `${prefix}/model-id` }); } + // For OpenAI/Anthropic-compatible providers, also add a dummy model + // even if testStatus is not active — prevents permanently disabled Apply button (#2994) + if (fallbackModels.length === 0 && (conn.providerSpecificData?.baseUrl || conn.providerSpecificData?.prefix)) { + fallbackModels.push({ id: "model-id", name: `${prefix}/model-id` }); + } fallbackModels.forEach(m => { const modelValue = `${prefix}/${m.id}`; if (!seenModels.has(modelValue)) { diff --git a/src/app/api/health/route.js b/src/app/api/health/route.js index 5021b7d181..ecdd572541 100644 --- a/src/app/api/health/route.js +++ b/src/app/api/health/route.js @@ -1,15 +1,22 @@ import { NextResponse } from "next/server"; +import { getProviderHealth } from "@/lib/providers/health.js"; -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "*", -}; +export async function GET(request) { + const { searchParams } = new URL(request.url); + const provider = searchParams.get("provider"); -export async function GET() { - return NextResponse.json({ ok: true }, { headers: CORS_HEADERS }); -} - -export async function OPTIONS() { - return new NextResponse(null, { status: 204, headers: CORS_HEADERS }); -} + try { + const health = await getProviderHealth(provider); + return NextResponse.json({ + status: "ok", + timestamp: new Date().toISOString(), + providers: health + }); + } catch (error) { + return NextResponse.json({ + status: "degraded", + timestamp: new Date().toISOString(), + error: error.message + }, { status: 503 }); + } +} \ No newline at end of file diff --git a/src/app/api/latency/route.js b/src/app/api/latency/route.js new file mode 100644 index 0000000000..c3f014c8c1 --- /dev/null +++ b/src/app/api/latency/route.js @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { getAllLatencyStats } from "@/lib/latencyMonitor.js"; + +export async function GET() { + const stats = getAllLatencyStats(); + return NextResponse.json({ + providers: stats, + count: stats.length, + timestamp: new Date().toISOString(), + }); +} diff --git a/src/app/api/models/test/ping.js b/src/app/api/models/test/ping.js index f7ea92aca7..0ca9fc4045 100644 --- a/src/app/api/models/test/ping.js +++ b/src/app/api/models/test/ping.js @@ -135,9 +135,10 @@ export async function pingModelByKind(model, kind, baseUrl = `http://127.0.0.1:$ headers, body: JSON.stringify({ model, - // Claude-on-Copilot returns empty choices at max_tokens:1 (budget is spent - // before a content token emits), so a 1-token probe yields a false negative. - max_tokens: 16, + // Use higher max_tokens for reasoning models to avoid empty choices + // Reasoning models (e.g., deepseek-reasoner, deepseek-v4-pro, claude-opus-4, o1) + // spend budget on thinking before emitting content tokens. + max_tokens: /reasoner|reasoning|thinking|o1|opus-4|pro-max/.test(model) ? 256 : 16, stream: false, messages: [{ role: "user", content: "hi" }], }), diff --git a/src/app/api/v1/chat/completions/route.js b/src/app/api/v1/chat/completions/route.js index ddb55122ed..f112b14376 100644 --- a/src/app/api/v1/chat/completions/route.js +++ b/src/app/api/v1/chat/completions/route.js @@ -1,5 +1,6 @@ import { handleChat } from "@/sse/handlers/chat.js"; import { initTranslators } from "open-sse/translator/index.js"; +import { rateLimit } from "@/lib/rate-limit.js"; let initialized = false; @@ -26,10 +27,44 @@ export async function OPTIONS() { }); } -export async function POST(request) { +export async function POST(request) { + // Rate limiting + const rl = rateLimit(request); + if (!rl.allowed) { + return new Response(JSON.stringify({ error: "Rate limit exceeded" }), { + status: 429, + headers: { + "Content-Type": "application/json", + ...rl.headers, + }, + }); + } + // Fallback to local handling await ensureInitialized(); - - return await handleChat(request); + + try { + const response = await handleChat(request); + + // Add rate limit headers to response + const newHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(rl.headers)) { + newHeaders.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + } catch (error) { + console.error("[Chat] POST handler error:", error); + return new Response(JSON.stringify({ + error: { message: error?.message || "Internal server error", type: "server_error" } + }), { + status: 500, + headers: { "Content-Type": "application/json", ...rl.headers }, + }); + } } diff --git a/src/app/api/v1/embeddings/route.js b/src/app/api/v1/embeddings/route.js index 9ae873d14f..696c4668e5 100644 --- a/src/app/api/v1/embeddings/route.js +++ b/src/app/api/v1/embeddings/route.js @@ -1,4 +1,5 @@ import { handleEmbeddings } from "@/sse/handlers/embeddings.js"; +import { rateLimit } from "@/lib/rate-limit.js"; /** * Handle CORS preflight @@ -17,5 +18,29 @@ export async function OPTIONS() { * POST /v1/embeddings - OpenAI-compatible embeddings endpoint */ export async function POST(request) { - return await handleEmbeddings(request); + // Rate limiting + const rl = rateLimit(request); + if (!rl.allowed) { + return new Response(JSON.stringify({ error: "Rate limit exceeded" }), { + status: 429, + headers: { + "Content-Type": "application/json", + ...rl.headers, + }, + }); + } + + const response = await handleEmbeddings(request); + + // Add rate limit headers to response + const newHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(rl.headers)) { + newHeaders.set(key, value); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); } diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 26c9d01080..19762d30a6 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -5,7 +5,7 @@ import { isAnthropicCompatibleProvider, isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import { getProviderConnections, getCombos, getCustomModels, getModelAliases } from "@/lib/localDb"; +import { getProviderConnections, getCombos, getCustomModels, getModelAliases, getSettings, validateApiKey } from "@/lib/localDb"; import { getDisabledModels } from "@/lib/disabledModelsDb"; import { resolveKiroModels } from "open-sse/services/kiroModels.js"; import { resolveKimchiModels } from "open-sse/services/kimchiModels.js"; @@ -375,7 +375,11 @@ export async function buildModelsList(kindFilter, options = {}) { ) : providerModels.map((model) => model.id); - if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) { + // For custom providers (OpenAI/Anthropic-compatible), if enabledModels is + // configured, always use those — never fetch ALL models from the provider (#3115) + if (isCompatibleProvider && hasExplicitEnabledModels && rawModelIds.length > 0) { + // Use enabledModels as-is (already set above) + } else if (isCompatibleProvider && rawModelIds.length === 0 && !skipDynamicFetch) { rawModelIds = await fetchCompatibleModelIds(conn); } @@ -533,14 +537,40 @@ export async function OPTIONS() { }); } +/** + * Extract API key from request (Bearer, x-api-key, x-goog-api-key, or query param). + */ +function extractApiKey(request) { + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); + const apiKeyHeader = request.headers.get("x-api-key"); + if (apiKeyHeader) return apiKeyHeader; + const googleApiKeyHeader = request.headers.get("x-goog-api-key"); + if (googleApiKeyHeader) return googleApiKeyHeader; + return request.nextUrl.searchParams?.get("key") || null; +} + /** * GET /v1/models - OpenAI compatible models list (LLM/chat models only by default). * For other capabilities use /v1/models/{kind} (image, tts, stt, embedding, image-to-text, web). */ export async function GET(request) { try { - // Detect cross-instance recursive /models fetch (another 9router fetching our /models) + // Enforce API key if requireApiKey is enabled (skip for internal cross-instance fetches) const skipDynamicFetch = request?.headers?.get(INTERNAL_MODELS_FETCH_HEADER) === "1"; + if (!skipDynamicFetch) { + const settings = await getSettings(); + if (settings?.requireApiKey) { + const apiKey = extractApiKey(request); + if (!apiKey) { + return Response.json({ error: { message: "API key required", type: "authentication_error" } }, { status: 401 }); + } + const valid = await validateApiKey(apiKey); + if (!valid) { + return Response.json({ error: { message: "Invalid API key", type: "authentication_error" } }, { status: 401 }); + } + } + } const data = await buildModelsList([LLM_KIND], { skipDynamicFetch }); return Response.json({ object: "list", data }, { headers: { "Access-Control-Allow-Origin": "*" }, diff --git a/src/app/layout.js b/src/app/layout.js index 5ea6c11db5..b6c125c94f 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,5 +1,4 @@ import { Inter } from "next/font/google"; -import { GoogleAnalytics } from "@next/third-parties/google"; import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; @@ -44,7 +43,6 @@ export default function RootLayout({ children }) { {children} - ); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 3f90c4f847..1b953683e4 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -29,6 +29,7 @@ const PUBLIC_API_PATHS = [ "/api/auth/oidc", "/api/version", "/api/settings/require-login", + "/api/headroom/extras", // Headroom extras status — no auth needed (#2965) ]; // Public top-level prefixes (LLM API endpoints with their own API key auth). @@ -82,6 +83,8 @@ const LOCAL_ONLY_PATHS = [ "/api/headroom/start", "/api/headroom/stop", "/api/headroom/proxy", + "/api/pxpipe/start", + "/api/pxpipe/stop", ]; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); @@ -141,6 +144,9 @@ async function canAccessPublicLlmApi(request) { async function canAccessLocalOnlyRoute(request) { if (await hasValidCliToken(request)) return true; + // When requireLogin is disabled, allow local browser access to headroom routes (#2916) + const settings = await loadSettings(); + if (settings && settings.requireLogin === false && isLocalRequest(request)) return true; // Browser on host: loopback Host + Origin (blocks tunnel/CSRF) + auth (JWT or requireLogin=false) if (isLocalRequest(request) && await isAuthenticated(request)) return true; return false; @@ -256,5 +262,23 @@ export async function proxy(request) { return NextResponse.redirect(new URL("/dashboard", request.url)); } + // If user has an active session and visits /login, redirect to /dashboard + // (prevents the login page from showing to already-authenticated users) + if (pathname === "/login") { + let requireLogin = true; + try { + const settings = await loadSettings(); + if (settings) requireLogin = settings.requireLogin !== false; + } catch {} + if (requireLogin) { + const token = request.cookies.get("auth_token")?.value; + if (token && await verifyDashboardAuthToken(token)) { + return NextResponse.redirect(new URL("/dashboard", request.url)); + } + } else { + return NextResponse.redirect(new URL("/dashboard", request.url)); + } + } + return NextResponse.next(); } diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 7bae5b9fe3..e78c6146a4 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -24,7 +24,7 @@ const DEFAULT_SETTINGS = { videoInput: { enabled: false, roundRobin: false, models: [] }, }, requireLogin: true, - requireApiKey: true, + requireApiKey: process.env.REQUIRE_API_KEY === "true" || false, tunnelDashboardAccess: true, authMode: "password", oidcIssuerUrl: "", diff --git a/src/lib/latencyMonitor.js b/src/lib/latencyMonitor.js new file mode 100644 index 0000000000..7fb900bc9b --- /dev/null +++ b/src/lib/latencyMonitor.js @@ -0,0 +1,107 @@ +/** + * Provider latency tracking and monitoring. + * Records per-provider response latency for intelligent provider selection. + * Issue #3072: Feature Request: Latency Monitoring for Provider Selection + */ + +import { appendRequestLog } from "@/lib/usageDb.js"; + +const LATENCY_WINDOW_MS = 5 * 60 * 1000; // 5 min rolling window +const MAX_SAMPLES = 100; + +// { [providerKey]: { samples: [{ ttft, total, ts }], rollingAvg: { ttft, total } } } +const latencyStore = new Map(); + +function providerKey(provider, model) { + return `${provider}/${model || "*"}`; +} + +/** + * Record a latency sample for a provider/model. + * @param {string} provider + * @param {string} model + * @param {{ttft: number, total: number}} latency + */ +export function recordLatency(provider, model, latency) { + if (!provider || !latency) return; + const key = providerKey(provider, model); + const now = Date.now(); + const { ttft = 0, total = 0 } = latency; + + let entry = latencyStore.get(key); + if (!entry) { + entry = { samples: [], rollingAvg: { ttft: 0, total: 0 } }; + latencyStore.set(key, entry); + } + + entry.samples.push({ ttft, total, ts: now }); + + // Trim: remove entries older than window, cap to MAX_SAMPLES + const cutoff = now - LATENCY_WINDOW_MS; + entry.samples = entry.samples.filter(s => s.ts >= cutoff).slice(-MAX_SAMPLES); + + // Recalculate rolling average + if (entry.samples.length > 0) { + const sum = entry.samples.reduce( + (acc, s) => ({ ttft: acc.ttft + s.ttft, total: acc.total + s.total }), + { ttft: 0, total: 0 } + ); + entry.rollingAvg = { + ttft: Math.round(sum.ttft / entry.samples.length), + total: Math.round(sum.total / entry.samples.length), + samples: entry.samples.length, + }; + } +} + +/** + * Get latency stats for a specific provider/model. + * @param {string} provider + * @param {string} model + * @returns {{ttft: number, total: number, samples: number} | null} + */ +export function getLatency(provider, model) { + const entry = latencyStore.get(providerKey(provider, model)); + if (!entry || entry.samples.length === 0) return null; + return entry.rollingAvg; +} + +/** + * Get all latency stats, sorted by fastest TTFT. + * @returns {Array<{provider: string, model: string, ttft: number, total: number, samples: number}>} + */ +export function getAllLatencyStats() { + const result = []; + for (const [key, entry] of latencyStore) { + if (entry.samples.length === 0) continue; + const [provider, model] = key.split("/"); + result.push({ + provider, + model: model === "*" ? null : model, + ...entry.rollingAvg, + }); + } + return result.sort((a, b) => a.ttft - b.ttft); +} + +/** + * Pick the fastest provider from a list of candidates. + * Falls back to first if no latency data exists. + * @param {Array<{provider: string, model?: string}>} candidates + * @returns {{provider: string, model?: string} | null} + */ +export function pickFastestProvider(candidates) { + if (!candidates || candidates.length === 0) return null; + if (candidates.length === 1) return candidates[0]; + + let best = null; + let bestTtft = Infinity; + for (const c of candidates) { + const lat = getLatency(c.provider, c.model); + if (lat && lat.ttft < bestTtft) { + bestTtft = lat.ttft; + best = c; + } + } + return best || candidates[0]; +} diff --git a/src/lib/providerNormalization.js b/src/lib/providerNormalization.js index 8eb2a7e374..317d4b8f2c 100644 --- a/src/lib/providerNormalization.js +++ b/src/lib/providerNormalization.js @@ -41,5 +41,27 @@ export function normalizeProviderSpecificData(provider, body = {}, providerSpeci if (baseUrl) next.baseUrl = baseUrl; } + // NVIDIA multi-service provider (LLM + TTS + Embedding) + if (provider === "nvidia") { + if (!next.ttsConfig) { + next.ttsConfig = { + baseUrl: "https://integrate.api.nvidia.com/v1/audio/speech", + authType: "apikey", + authHeader: "bearer", + format: "nvidia-tts", + }; + } + if (!next.embeddingConfig) { + next.embeddingConfig = { + baseUrl: "https://integrate.api.nvidia.com/v1/embeddings", + authType: "apikey", + authHeader: "bearer", + }; + } + if (!next.serviceKinds) { + next.serviceKinds = ["llm", "tts", "embedding"]; + } + } + return Object.keys(next).length > 0 ? next : null; } diff --git a/src/lib/providers/health.js b/src/lib/providers/health.js new file mode 100644 index 0000000000..b8b4ab3f72 --- /dev/null +++ b/src/lib/providers/health.js @@ -0,0 +1,51 @@ +/** + * Provider health check module. + * Checks the health of provider API endpoints by making test requests. + */ + +import { PROVIDERS } from "@/open-sse/providers/index.js"; + +export async function getProviderHealth(providerId = null) { + const providers = providerId + ? { [providerId]: PROVIDERS[providerId] } + : PROVIDERS; + + const results = {}; + + for (const [id, config] of Object.entries(providers)) { + if (!config || !config.baseUrl) continue; + + const start = Date.now(); + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch(config.baseUrl, { + method: "GET", + signal: controller.signal, + headers: { + "Accept": "application/json", + "User-Agent": "9Router-HealthCheck/1.0" + } + }).catch(() => null); + + clearTimeout(timeout); + + results[id] = { + status: response?.ok ? "reachable" : "unreachable", + latency: Date.now() - start, + statusCode: response?.status || 0, + provider: config.name || id + }; + } catch { + results[id] = { + status: "error", + latency: Date.now() - start, + statusCode: 0, + provider: config.name || id + }; + } + } + + return results; +} \ No newline at end of file diff --git a/src/lib/rate-limit.js b/src/lib/rate-limit.js new file mode 100644 index 0000000000..590282da93 --- /dev/null +++ b/src/lib/rate-limit.js @@ -0,0 +1,85 @@ +/** + * Rate limiting middleware for 9Router API endpoints. + * Implements token bucket algorithm with configurable limits per endpoint type. + */ + +const rateLimitStore = new Map(); +const DEFAULT_LIMITS = { + chat: { requests: 100, windowMs: 60000 }, // 100 req/min + embeddings: { requests: 200, windowMs: 60000 }, // 200 req/min + models: { requests: 500, windowMs: 60000 }, // 500 req/min + search: { requests: 30, windowMs: 60000 }, // 30 req/min + default: { requests: 100, windowMs: 60000 }, // default 100 req/min +}; + +function getClientId(request) { + // Use IP + User-Agent for client identification + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() + || request.headers.get("x-real-ip") + || "unknown"; + const ua = request.headers.get("user-agent") || ""; + return `${ip}:${ua.slice(0, 50)}`; +} + +function getEndpointType(pathname) { + if (pathname.includes("/v1/chat/completions")) return "chat"; + if (pathname.includes("/v1/embeddings")) return "embeddings"; + if (pathname.includes("/v1/models")) return "models"; + if (pathname.includes("/v1/search")) return "search"; + return "default"; +} + +export function rateLimit(request, customLimits = {}) { + const clientId = getClientId(request); + const endpointType = getEndpointType(new URL(request.url).pathname); + const limits = { ...DEFAULT_LIMITS, ...customLimits }; + const limit = limits[endpointType] || limits.default; + + const now = Date.now(); + const key = `${clientId}:${endpointType}`; + const bucket = rateLimitStore.get(key) || { count: 0, windowStart: now }; + + // Reset window if expired + if (now - bucket.windowStart >= limit.windowMs) { + bucket.count = 0; + bucket.windowStart = now; + } + + bucket.count++; + rateLimitStore.set(key, bucket); + + const remaining = Math.max(0, limit.requests - bucket.count); + const resetTime = bucket.windowStart + limit.windowMs; + const retryAfter = Math.ceil((resetTime - now) / 1000); + + const headers = { + "X-RateLimit-Limit": String(limit.requests), + "X-RateLimit-Remaining": String(remaining), + "X-RateLimit-Reset": String(Math.ceil(resetTime / 1000)), + }; + + if (bucket.count > limit.requests) { + return { + allowed: false, + headers: { + ...headers, + "Retry-After": String(retryAfter), + }, + retryAfter, + }; + } + + return { allowed: true, headers }; +} + +// Periodic cleanup of old entries +setInterval(() => { + const now = Date.now(); + for (const [key, bucket] of rateLimitStore.entries()) { + if (now - bucket.windowStart > 300000) { // 5 minutes + rateLimitStore.delete(key); + } + } +}, 60000); + +export default rateLimit; \ No newline at end of file diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index ecab40b155..3348d7ee33 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -277,8 +277,14 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro }, [period]); // SSE connection - real-time updates for activeRequests + recentRequests only + // Limit to one EventSource per page (prevents busy loop that takes down server #3061) + const esRef = useRef(null); useEffect(() => { + // Prevent duplicate EventSource connections across re-renders + if (esRef.current) return; + const es = new EventSource("/api/usage/stream"); + esRef.current = es; es.onmessage = (e) => { try { @@ -302,7 +308,10 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro es.onerror = () => setLoading(false); - return () => es.close(); + return () => { + es.close(); + esRef.current = null; + }; }, []); const toggleSort = useCallback((tableType, field) => { diff --git a/src/shared/utils/formatCost.js b/src/shared/utils/formatCost.js new file mode 100644 index 0000000000..408f412ca1 --- /dev/null +++ b/src/shared/utils/formatCost.js @@ -0,0 +1,93 @@ +/** + * Locale-aware cost formatting. + * Uses Intl.NumberFormat with the user's locale to display costs. + * Defaults to USD ($) but adapts to CNY (¥), EUR (€), etc. + * + * Issue #2976: Usage cost display should follow UI locale + */ + +// Map of locale prefixes to currency codes for common non-USD locales +const LOCALE_CURRENCY_MAP = { + "zh": "CNY", + "ja": "JPY", + "ko": "KRW", + "pt-BR": "BRL", + "pt-PT": "EUR", + "es": "EUR", + "de": "EUR", + "fr": "EUR", + "ru": "RUB", + "pl": "PLN", + "cs": "CZK", + "nl": "EUR", + "tr": "TRY", + "uk": "UAH", + "th": "THB", + "hi": "INR", + "bn": "BDT", + "ar": "SAR", + "he": "ILS", +}; + +/** + * Get the currency code for a locale + * @param {string} locale - Browser locale (e.g. "zh-CN", "pt-BR", "en") + * @returns {string} Currency code (e.g. "CNY", "BRL", "USD") + */ +function getCurrencyForLocale(locale) { + if (!locale) return "USD"; + // Check full locale first (e.g. "pt-BR"), then base language (e.g. "zh") + const lang = locale.split("-")[0]; + return LOCALE_CURRENCY_MAP[locale] || LOCALE_CURRENCY_MAP[lang] || "USD"; +} + +// Cache the formatter for performance +let _cachedLocale = null; +let _cachedFormatter = null; + +/** + * Get a locale-aware currency formatter + * @param {string} [locale] - Optional locale override; auto-detected if omitted + * @returns {Intl.NumberFormat} + */ +function getCostFormatter(locale) { + if (!locale && _cachedFormatter) return _cachedFormatter; + + const effectiveLocale = locale || (typeof navigator !== "undefined" ? navigator.language : "en"); + const currency = getCurrencyForLocale(effectiveLocale); + + const formatter = new Intl.NumberFormat(effectiveLocale, { + style: "currency", + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 4, + }); + + if (!locale) { + _cachedLocale = effectiveLocale; + _cachedFormatter = formatter; + } + + return formatter; +} + +/** + * Format a cost value using the user's locale. + * + * @param {number|null|undefined} cost - Cost in dollars (or equivalent base unit) + * @param {string} [locale] - Optional locale override (e.g. "zh-CN") + * @returns {string} Formatted cost string (e.g. "$1.23", "¥1.23", "€1.23") + */ +export function fmtCost(cost, locale) { + if (cost === null || cost === undefined || isNaN(cost)) { + return getCostFormatter(locale).format(0); + } + return getCostFormatter(locale).format(cost); +} + +/** + * Legacy-compatible formatCost that returns a plain string. + * Used by PricingModal and other components that need the old $ format + * but can be upgraded to use fmtCost() for locale support. + */ +export { fmtCost as formatCost }; diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js index d8ee6b7465..61e974c9ba 100644 --- a/src/sse/handlers/search.js +++ b/src/sse/handlers/search.js @@ -114,6 +114,23 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey, log.info("ROUTING", `Provider: ${providerId}`); } + // SSRF guard: Only forward a whitelist of safe options from provider_options. + // The raw object used to be forwarded unvalidated, allowing a client to + // override `baseUrl` and exfiltrate credentials to an attacker-controlled + // SearXNG instance (https://github.com/decolua/9router/issues/3049). + const safeProviderOptions = {}; + if (body.provider_options && typeof body.provider_options === "object" && !Array.isArray(body.provider_options)) { + const SAFE_KEYS = [ + "safesearch", "categories", "engines", "format", + "pageno", "image_proxy", "autocomplete", "theme", + ]; + for (const key of SAFE_KEYS) { + if (key in body.provider_options) { + safeProviderOptions[key] = body.provider_options[key]; + } + } + } + // Sanitized body forwarded to core const coreBody = { query: query.trim(), @@ -126,7 +143,7 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey, offset: body.offset, domain_filter: body.domain_filter, content_options: body.content_options, - provider_options: body.provider_options + provider_options: safeProviderOptions }; // No-auth providers (e.g. searxng) bypass credential lookup diff --git a/tests/translator/__snapshots__/golden-request.test.js.snap b/tests/translator/__snapshots__/golden-request.test.js.snap index a7a0c219ae..64eea1949d 100644 --- a/tests/translator/__snapshots__/golden-request.test.js.snap +++ b/tests/translator/__snapshots__/golden-request.test.js.snap @@ -51,10 +51,6 @@ exports[`GOLDEN request: OpenAI → Claude > full body (system/image/tool/tool_r "model": "claude-opus-4-6", "stream": true, "system": [ - { - "text": "You are Claude Code, Anthropic's official CLI for Claude.", - "type": "text", - }, { "cache_control": { "ttl": "1h", @@ -108,16 +104,6 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive outpu "effort": "high", }, "stream": true, - "system": [ - { - "cache_control": { - "ttl": "1h", - "type": "ephemeral", - }, - "text": "You are Claude Code, Anthropic's official CLI for Claude.", - "type": "text", - }, - ], "thinking": { "type": "adaptive", }, @@ -233,7 +219,9 @@ exports[`GOLDEN request: OpenAI → Gemini > full body (system/image/tool/tool_r exports[`GOLDEN request: OpenAI → Kiro > full body (image base64 + tool_result) 1`] = ` { + "agentMode": "vibe", "conversationState": { + "agentTaskType": "vibe", "chatTriggerType": "MANUAL", "currentMessage": { "userInputMessage": { @@ -281,7 +269,11 @@ continue", "history": [ { "userInputMessage": { - "content": "You are helpful. + "content": "[Context: Current time is + + +You are helpful. + What's in this image?", "images": [ diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap index 9482052c21..3953826a52 100644 --- a/tests/translator/__snapshots__/golden-url-header.test.js.snap +++ b/tests/translator/__snapshots__/golden-url-header.test.js.snap @@ -38,19 +38,36 @@ exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → hea } `; +exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "", }, "nonStream": { "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "", @@ -58,7 +75,6 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header "oauth": { "Accept": "text/event-stream", "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", "anthropic-version": "2023-06-01", "x-api-key": "", @@ -66,6 +82,31 @@ exports[`GOLDEN buildHeaders (default executor providers) > anthropic → header } `; +exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -85,6 +126,44 @@ exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → heade } `; +exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -104,6 +183,25 @@ exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers } `; +exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = ` { "apiKey": { @@ -229,26 +327,72 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a "Authorization": "Bearer ", "Content-Type": "application/json", "HTTP-Referer": "https://cline.bot", - "User-Agent": "9Router/0.4.80", + "User-Agent": "9Router/0.5.50", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", + "X-Title": "Cline", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.50", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", + "X-Title": "Cline", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.50", + "X-CLIENT-TYPE": "9router", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", + "X-Title": "Cline", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "9Router/0.5.50", "X-CLIENT-TYPE": "9router", - "X-CLIENT-VERSION": "0.4.80", - "X-CORE-VERSION": "0.4.80", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", "X-IS-MULTIROOT": "false", - "X-PLATFORM": "darwin", - "X-PLATFORM-VERSION": "v22.22.0", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", "X-Title": "Cline", }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", "HTTP-Referer": "https://cline.bot", - "User-Agent": "9Router/0.4.80", + "User-Agent": "9Router/0.5.50", "X-CLIENT-TYPE": "9router", - "X-CLIENT-VERSION": "0.4.80", - "X-CORE-VERSION": "0.4.80", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", "X-IS-MULTIROOT": "false", - "X-PLATFORM": "darwin", - "X-PLATFORM-VERSION": "v22.22.0", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", "X-Title": "Cline", }, "oauth": { @@ -256,13 +400,13 @@ exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (a "Authorization": "Bearer ", "Content-Type": "application/json", "HTTP-Referer": "https://cline.bot", - "User-Agent": "9Router/0.4.80", + "User-Agent": "9Router/0.5.50", "X-CLIENT-TYPE": "9router", - "X-CLIENT-VERSION": "0.4.80", - "X-CORE-VERSION": "0.4.80", + "X-CLIENT-VERSION": "0.5.50", + "X-CORE-VERSION": "0.5.50", "X-IS-MULTIROOT": "false", - "X-PLATFORM": "darwin", - "X-PLATFORM-VERSION": "v22.22.0", + "X-PLATFORM": "linux", + "X-PLATFORM-VERSION": "v24.17.0", "X-Title": "Cline", }, } @@ -324,7 +468,406 @@ exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → hea } `; -exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > deepgram → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > gemini → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-goog-api-key": "", + }, + "nonStream": { + "Content-Type": "application/json", + "x-goog-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > gitlab → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > glm → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > joycode → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "debian", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.50", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "debian", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.50", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "debian", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.50", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -343,45 +886,57 @@ exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > deepgram → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, "nonStream": { - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > minimax-cn → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, "nonStream": { - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", + "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > mistral → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -400,16 +955,16 @@ exports[`GOLDEN buildHeaders (default executor providers) > fireworks → header } `; -exports[`GOLDEN buildHeaders (default executor providers) > gemini → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-goog-api-key": "", }, "nonStream": { + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-goog-api-key": "", }, "oauth": { "Accept": "text/event-stream", @@ -419,7 +974,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > gemini → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > gitlab → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -438,32 +993,26 @@ exports[`GOLDEN buildHeaders (default executor providers) > gitlab → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > glm → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > nebius → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -482,7 +1031,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > nvidia → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -501,7 +1050,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (ap } `; -exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > ollama → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -520,7 +1069,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → heade } `; -exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > openai → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -539,119 +1088,89 @@ exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers } `; -exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > openmodel → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > kimi-coding → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > openrouter → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "X-Msh-Device-Id": "kimi-", - "X-Msh-Device-Model": "darwin arm64", - "X-Msh-Platform": "9router", - "X-Msh-Version": "2.1.2", - "x-api-key": "", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "X-Msh-Device-Id": "kimi-", - "X-Msh-Device-Model": "darwin arm64", - "X-Msh-Platform": "9router", - "X-Msh-Version": "2.1.2", - "x-api-key": "", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "X-Msh-Device-Id": "kimi-", - "X-Msh-Device-Model": "darwin arm64", - "X-Msh-Platform": "9router", - "X-Msh-Version": "2.1.2", - "x-api-key": "", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > ovh → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > minimax-cn → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > perplexity → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", - "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", "Content-Type": "application/json", - "x-api-key": "", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > mistral → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -670,7 +1189,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > mistral → headers } `; -exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -689,7 +1208,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (api } `; -exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > reasonix → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -708,7 +1227,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → heade } `; -exports[`GOLDEN buildHeaders (default executor providers) > nebius → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -727,7 +1246,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > nebius → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > nvidia → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -746,7 +1265,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > nvidia → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > ollama → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -765,7 +1284,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > ollama → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > openai → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -784,32 +1303,26 @@ exports[`GOLDEN buildHeaders (default executor providers) > openai → headers ( } `; -exports[`GOLDEN buildHeaders (default executor providers) > openrouter → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy", }, "nonStream": { "Authorization": "Bearer ", "Content-Type": "application/json", - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy", }, "oauth": { "Accept": "text/event-stream", "Authorization": "Bearer ", "Content-Type": "application/json", - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy", }, } `; -exports[`GOLDEN buildHeaders (default executor providers) > perplexity → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > trae → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -828,7 +1341,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > perplexity → heade } `; -exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -847,7 +1360,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → head } `; -exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -866,7 +1379,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > together → headers } `; -exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > volcengine-ark → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -885,7 +1398,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway } `; -exports[`GOLDEN buildHeaders (default executor providers) > volcengine-ark → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > xai → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -904,7 +1417,7 @@ exports[`GOLDEN buildHeaders (default executor providers) > volcengine-ark → h } `; -exports[`GOLDEN buildHeaders (default executor providers) > xai → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", @@ -923,21 +1436,24 @@ exports[`GOLDEN buildHeaders (default executor providers) > xai → headers (api } `; -exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → headers (apiKey / oauth) 1`] = ` +exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = ` { "apiKey": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Authorization": "", "Content-Type": "application/json", + "content-type": "application/json", }, "nonStream": { - "Authorization": "Bearer ", + "Authorization": "", "Content-Type": "application/json", + "content-type": "application/json", }, "oauth": { "Accept": "text/event-stream", - "Authorization": "Bearer ", + "Authorization": "", "Content-Type": "application/json", + "content-type": "application/json", }, } `; @@ -956,6 +1472,13 @@ exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (st } `; +exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.anthropic.com/v1/messages", @@ -963,6 +1486,13 @@ exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (strea } `; +exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.airforce/v1/chat/completions", + "stream": "https://api.airforce/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.assemblyai.com/v1/audio/transcriptions", @@ -970,10 +1500,31 @@ exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://qianfan.baidubce.com/v2/chat/completions", + "stream": "https://qianfan.baidubce.com/v2/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://bazaarlink.ai/api/v1/chat/completions", + "stream": "https://bazaarlink.ai/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = ` { - "nonStream": "https://api.blackbox.ai/chat/completions", - "stream": "https://api.blackbox.ai/chat/completions", + "nonStream": "https://api.blackbox.ai/v1/chat/completions", + "stream": "https://api.blackbox.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.bluesminds.com/v1/chat/completions", + "stream": "https://api.bluesminds.com/v1/chat/completions", } `; @@ -1012,6 +1563,13 @@ exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream + } `; +exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions", @@ -1026,6 +1584,13 @@ exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (st } `; +exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://www.codebuddy.ai/v2/chat/completions", + "stream": "https://www.codebuddy.ai/v2/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.cohere.ai/v1/chat/completions", @@ -1047,6 +1612,13 @@ exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.featherless.ai/v1/chat/completions", + "stream": "https://api.featherless.ai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.fireworks.ai/inference/v1/chat/completions", @@ -1082,6 +1654,13 @@ exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream + } `; +exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cli-chat-proxy.grok.com/v1/responses", + "stream": "https://cli-chat-proxy.grok.com/v1/responses", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.groq.com/openai/v1/chat/completions", @@ -1096,6 +1675,20 @@ exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > joycode → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.joycode.jd.com/v1/chat/completions", + "stream": "https://api.joycode.jd.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.kilo.ai/api/gateway/chat/completions", + "stream": "https://api.kilo.ai/api/gateway/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.kilo.ai/api/openrouter/chat/completions", @@ -1103,6 +1696,13 @@ exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions", + "stream": "https://llm.kimchi.dev/openai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true", @@ -1110,10 +1710,10 @@ exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + n } `; -exports[`GOLDEN buildUrl (default executor providers) > kimi-coding → url (stream + non-stream) 1`] = ` +exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = ` { - "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true", - "stream": "https://api.kimi.com/coding/v1/messages?beta=true", + "nonStream": "https://api.llm7.io/v1/chat/completions", + "stream": "https://api.llm7.io/v1/chat/completions", } `; @@ -1145,6 +1745,13 @@ exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + no } `; +exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.morphllm.com/v1/chat/completions", + "stream": "https://api.morphllm.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.nanobananaapi.ai/v1/chat/completions", @@ -1180,6 +1787,13 @@ exports[`GOLDEN buildUrl (default executor providers) > openai → url (stream + } `; +exports[`GOLDEN buildUrl (default executor providers) > openmodel → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.openmodel.ai/v1/chat/completions", + "stream": "https://api.openmodel.ai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > openrouter → url (stream + non-stream) 1`] = ` { "nonStream": "https://openrouter.ai/api/v1/chat/completions", @@ -1187,6 +1801,13 @@ exports[`GOLDEN buildUrl (default executor providers) > openrouter → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > ovh → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://ai.endpoints.ovh.com/v1/chat/completions", + "stream": "https://ai.endpoints.ovh.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.perplexity.ai/chat/completions", @@ -1194,6 +1815,34 @@ exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stre } `; +exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.perplexity.ai/v1/responses", + "stream": "https://api.perplexity.ai/v1/responses", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://inference.poolside.ai/v1/chat/completions", + "stream": "https://inference.poolside.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > reasonix → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.reasonix.ai/v1/chat/completions", + "stream": "https://api.reasonix.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.sambanova.ai/v1/chat/completions", + "stream": "https://api.sambanova.ai/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.siliconflow.com/v1/chat/completions", @@ -1201,6 +1850,13 @@ exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (str } `; +exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = ` { "nonStream": "https://api.together.xyz/v1/chat/completions", @@ -1208,6 +1864,27 @@ exports[`GOLDEN buildUrl (default executor providers) > together → url (stream } `; +exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.tokenrouter.com/v1/chat/completions", + "stream": "https://api.tokenrouter.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > trae → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.trae.ai/v1/chat/completions", + "stream": "https://api.trae.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.venice.ai/api/v1/chat/completions", + "stream": "https://api.venice.ai/api/v1/chat/completions", +} +`; + exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = ` { "nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions", @@ -1235,3 +1912,10 @@ exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (str "stream": "https://api.xiaomimimo.com/v1/chat/completions", } `; + +exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cloud.zed.dev/completions", + "stream": "https://cloud.zed.dev/completions", +} +`; diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 3fca7be15e..350cc3c0cf 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -33,7 +33,6 @@ describe("Claude → Kiro (direct route)", () => { first.conversationState.currentMessage.userInputMessage.content ); expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.5"); - expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time"); expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second"); }); @@ -81,9 +80,8 @@ describe("Claude → Kiro (direct route)", () => { null, "kiro" ); - expect(out.systemPrompt).toContain( - "enabled" - ); + // System prompt is now in currentMessage via contentPrefix + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("enabled"); expect(out.agentMode).toBe("vibe"); }); @@ -95,7 +93,8 @@ describe("Claude → Kiro (direct route)", () => { expect(out.additionalModelRequestFields).toBeUndefined(); expect(out.thinking).toBeUndefined(); - expect(out.systemPrompt).toContain("24576"); + // System prompt with thinking tag is now in currentMessage + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("24576"); }); it("normalizes an unsupported Kiro intensity suffix while preserving agentic behavior", () => { @@ -107,7 +106,8 @@ describe("Claude → Kiro (direct route)", () => { expect(out.conversationState.currentMessage.userInputMessage.modelId).toBe("claude-sonnet-4.5"); expect(out.additionalModelRequestFields).toBeUndefined(); - expect(out.systemPrompt).toContain("CHUNKED WRITE PROTOCOL"); + // Agentic system prompt is now in currentMessage + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("CHUNKED WRITE PROTOCOL"); }); it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => { @@ -121,7 +121,8 @@ describe("Claude → Kiro (direct route)", () => { output_config: { effort: "high" }, }); expect(out.thinking).toBeUndefined(); - expect(out.systemPrompt).toContain("24576"); + // Legacy thinking fallback is now in currentMessage + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("24576"); }); it("maps Claude-format effort to GPT-5.6 reasoning fields without legacy prompt tags", () => { @@ -146,8 +147,9 @@ describe("Claude → Kiro (direct route)", () => { }, null, "gpt-5.6-sol"); expect(out.additionalModelRequestFields).toBeUndefined(); - expect(out.systemPrompt).toContain("enabled"); - expect(out.systemPrompt).toContain(""); + // Legacy thinking fallback is now in currentMessage + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("enabled"); + expect(out.conversationState.currentMessage.userInputMessage.content).toContain(""); } ); @@ -177,17 +179,20 @@ describe("Claude → Kiro (direct route)", () => { }); }); - it("sends Claude system as top-level systemPrompt and keeps a user-content fallback", () => { + it("sends Claude system as user message with instructions tags", () => { const out = C2K({ system: "system-only instruction", messages: [{ role: "user", content: "hello" }], }); - expect(out.systemPrompt).toContain("system-only instruction"); + // System prompt is now in currentMessage via contentPrefix expect(out.conversationState.currentMessage.userInputMessage.content).toContain("system-only instruction"); + expect(out.conversationState.currentMessage.userInputMessage.content).toContain(""); + expect(out.conversationState.currentMessage.userInputMessage.content).toContain(""); + expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello"); }); - it("keeps top-level systemPrompt stable across turns", () => { + it("keeps system prompt stable across turns (now in currentMessage)", () => { const first = C2K({ system: "stable instruction", messages: [{ role: "user", content: "first" }], @@ -197,9 +202,13 @@ describe("Claude → Kiro (direct route)", () => { messages: [{ role: "user", content: "second" }], }); - expect(first.systemPrompt).toBe(second.systemPrompt); - expect(first.systemPrompt).not.toContain("Current time"); - expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time"); + // System instruction appears in both currentMessages + expect(first.conversationState.currentMessage.userInputMessage.content).toContain("stable instruction"); + expect(second.conversationState.currentMessage.userInputMessage.content).toContain("stable instruction"); + expect(first.conversationState.currentMessage.userInputMessage.content).toContain(""); + expect(second.conversationState.currentMessage.userInputMessage.content).toContain(""); + expect(first.conversationState.currentMessage.userInputMessage.content).toContain("first"); + expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second"); }); }); diff --git a/tests/translator/golden-request.test.js b/tests/translator/golden-request.test.js index 21cf49f7f2..4cc0a050fb 100644 --- a/tests/translator/golden-request.test.js +++ b/tests/translator/golden-request.test.js @@ -27,10 +27,10 @@ function baseBody() { }; } -// Khử field động: toolNameMap, kiro conversationId (uuid), timestamp trong content. +// Khử field động: toolNameMap, kiro conversationId (uuid), timestamp trong content, agentContinuationId (uuid). function clean(body) { const s = JSON.stringify(body, (k, v) => { - if (k === "_toolNameMap" || k === "conversationId") return undefined; + if (k === "_toolNameMap" || k === "conversationId" || k === "agentContinuationId") return undefined; return v; }).replace(/Current time is [^"\\]+/g, "Current time is "); return JSON.parse(s); diff --git a/tests/translator/provider-config.test.js b/tests/translator/provider-config.test.js new file mode 100644 index 0000000000..9386eea7e1 --- /dev/null +++ b/tests/translator/provider-config.test.js @@ -0,0 +1,68 @@ +// Provider configuration and fallback test +import { describe, it, expect } from "vitest"; +import "./registerAll.js"; +import { translateRequest } from "../../open-sse/translator/index.js"; +import { FORMATS } from "../../open-sse/translator/formats.js"; + +describe("Provider configuration and fallback", () => { + it("should translate OpenAI to NVIDIA format correctly", () => { + const body = { + messages: [{ role: "user", content: "Hello" }], + model: "nvidia/nemotron-3-ultra-550b-a55b", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "nvidia/nemotron-3-ultra-550b-a55b", body, true, { apiKey: "test" }, "nvidia"); + expect(out.model).toBe("nvidia/nemotron-3-ultra-550b-a55b"); + expect(out.messages).toBeDefined(); + expect(Array.isArray(out.messages)).toBe(true); + }); + + it("should translate OpenAI to DeepSeek format with search enabled", () => { + const body = { + messages: [{ role: "user", content: "Search for something" }], + model: "deepseek-v4-flash", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "deepseek-v4-flash", body, true, { apiKey: "test" }, "deepseek"); + expect(out.model).toBe("deepseek-v4-flash"); + expect(out.messages).toBeDefined(); + }); + + it("should translate OpenAI to OVH format correctly", () => { + const body = { + messages: [{ role: "user", content: "Hello OVH" }], + model: "ovh/mistral-7b-instruct", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "ovh/mistral-7b-instruct", body, true, { apiKey: "test" }, "ovh"); + expect(out.model).toBe("ovh/mistral-7b-instruct"); + expect(out.messages).toBeDefined(); + }); + + it("should translate OpenAI to TRAE format correctly", () => { + const body = { + messages: [{ role: "user", content: "Hello TRAE" }], + model: "trae-v1", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "trae-v1", body, true, { apiKey: "test" }, "trae"); + expect(out.model).toBe("trae-v1"); + expect(out.messages).toBeDefined(); + }); + + it("should translate OpenAI to Reasonix format correctly", () => { + const body = { + messages: [{ role: "user", content: "Hello Reasonix" }], + model: "reasonix-v1", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "reasonix-v1", body, true, { apiKey: "test" }, "reasonix"); + expect(out.model).toBe("reasonix-v1"); + expect(out.messages).toBeDefined(); + }); + + it("should translate OpenAI to JoyCode format correctly", () => { + const body = { + messages: [{ role: "user", content: "Hello JoyCode" }], + model: "joycode-v1", + }; + const out = translateRequest(FORMATS.OPENAI, FORMATS.OPENAI, "joycode-v1", body, true, { apiKey: "test" }, "joycode"); + expect(out.model).toBe("joycode-v1"); + expect(out.messages).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/umbrel-app.yml b/umbrel-app.yml new file mode 100644 index 0000000000..1ef976b608 --- /dev/null +++ b/umbrel-app.yml @@ -0,0 +1,30 @@ +name: 9router +version: "0.5.50" +description: "9Router — Local AI routing gateway. One OpenAI-compatible endpoint routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account, and OAuth/usage tracking." +website: https://github.com/decolua/9router +submittable: true +tips: + github: + - decolua +support: https://github.com/decolua/9router/issues +gallery: + - 1.jpg + - 2.jpg + - 3.jpg +release: + date: "2026-08-05" + version: "0.5.50" +container: + tungstenFabric: false + monorepo: false + anyArchitecture: false + buildTime: "30 minutes" + startTimeout: 60 + restart: on-failure + uid: 1000 + gid: 1000 + build: + image: "" + dockerfile: "" + runners: [] + prereqs: []