Compare commits
10 commits
8ed9da7165
...
699edac327
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
699edac327 | ||
|
|
540ebbe682 | ||
|
|
e1115e2839 | ||
|
|
27f3710c8b | ||
|
|
59d858b639 | ||
|
|
92259214db | ||
|
|
b04c03c6b5 | ||
|
|
8b2b2fefb5 | ||
|
|
86694ed8d0 | ||
|
|
8af5e752da |
45 changed files with 3981 additions and 158 deletions
86
CHANGELOG.md
86
CHANGELOG.md
|
|
@ -1,3 +1,89 @@
|
|||
# v0.5.55 (2026-08-14)
|
||||
|
||||
## Features
|
||||
- **Auth**: native SAML 2.0 SSO alongside OIDC — AuthnRequest generation, ACS
|
||||
assertion handling, SP metadata export, admin config test, replay-protected
|
||||
via a `saml_state` cookie matched against `InResponseTo`
|
||||
- **Providers**: add Alibaba Token Plan (`token-plan.ap-southeast-1`) — the
|
||||
fourth Alibaba key type, Singapore-only and OpenAI-compatible transport only
|
||||
- **Providers**: add `glm-5.3` to GLM Coding and GLM (China)
|
||||
- **Providers**: Kimchi accepts API keys as well as OAuth (dual auth), with a
|
||||
working Test Connection for both modes
|
||||
- **Antigravity**: add Gemini 3.7 Flash and its tiered high/medium/low variants
|
||||
(also in the Gemini registry) with pricing and quota tracking
|
||||
- **TTS**: add Fish Audio — model id travels in an HTTP `model` header, voice
|
||||
is a `reference_id` (preset or cloned voice model)
|
||||
- **OpenCode-Go**: route by request format via declared transports instead of
|
||||
forcing every client into `/messages` — Codex/OpenAI clients no longer pay a
|
||||
lossy Responses→OpenAI→Claude double translation. Per-model `supportedFormats`
|
||||
guard; the bespoke executor is gone (its shared `_lastModel` cache could cross
|
||||
auth headers between concurrent requests)
|
||||
- **Usage**: dedup + cache Claude quota calls (120s TTL keyed by access token,
|
||||
in-flight promise dedup, last-good read on soft failure) to stop multiple
|
||||
tabs tripping 429; manual refresh (↻) sends `force=1` to bypass the cache
|
||||
|
||||
## Fixes
|
||||
- **Docker**: ship `sql.js` in the image so the pure-JS DB fallback can start —
|
||||
file tracing carried the package's JS without `dist/sql-wasm.wasm`, so a
|
||||
container with no native driver aborted with ENOENT and never got a database
|
||||
(#3248)
|
||||
- **Usage**: read Gemini `usageMetadata` out of the antigravity `{ response }`
|
||||
envelope — every non-streaming antigravity request logged `IN 0 | OUT 0`
|
||||
(#3260)
|
||||
- **Claude**: re-anchor passthrough cache breakpoints — the client's own
|
||||
`cache_control` markers point at pre-normalization offsets, so the tail was
|
||||
re-cached every request. Last system block and last tool pinned at 1h TTL,
|
||||
last assistant turn at 5m, mid-conversation system messages folded into the
|
||||
neighbouring user turn instead of hoisted into `body.system`
|
||||
- **Combos**: detect images from Hermes and attachment payloads (`images[]`,
|
||||
`experimental_attachments`, message-level `image_url`/`audio_url`, inline
|
||||
`data:` URIs) so the Vision Adapter auto-switch fires for Hermes/Ollama/
|
||||
Vercel AI SDK shapes
|
||||
- **Kiro**: intercept chat via `x-amz-target` — Kiro IDE 1.0.228+ moved
|
||||
`GenerateAssistantResponse` to `POST /` + header, bypassing MITM. Also emit
|
||||
the now-mandatory initial-response frame and map the `auto` model slot
|
||||
- **Kiro**: report real output tokens and stop discarding usable turns
|
||||
- **Qoder**: detect billing blocks at stream start and return a synthetic 403
|
||||
so combo/account fallback triggers instead of leaking the error into chat
|
||||
- **Antigravity**: strip competitive system prompts (Zed IDE's Claude-agent
|
||||
prompt) that Antigravity flags with a 429 Quota Exhausted
|
||||
- **OpenCode**: send the official client fingerprint on free-tier requests so
|
||||
the Console stops classifying traffic as unidentified and rate-limiting it;
|
||||
session id resolves conversation-stable to preserve prompt caching
|
||||
- **Responses**: don't close the message on an empty `tool_calls` array — some
|
||||
providers attach one to every chunk, and the truthy check ended the message
|
||||
on the first content token (#3234)
|
||||
- **Translator**: preserve `prompt_cache_key` when converting chat to responses
|
||||
- **Models**: expose snake_case token limits on `/v1/models`
|
||||
- **Combos**: strip `stream_options` from the Fusion panel fan-out to avoid a
|
||||
DeepSeek 400 (#3024); raise the dashboard model-test probe budget to 1024 and
|
||||
soft-pass reasoning-only responses (#3010)
|
||||
- **Headroom**: the toggle reflects the `headroomEnabled` setting even when the
|
||||
proxy is down — it previously showed OFF while the engine kept calling
|
||||
`/v1/compress`; proxy status stays visible via the status chip
|
||||
- **Hermes**: add the `api_key` parameter to the model block in YAML config
|
||||
- **Providers**: add llm7 to provider test support
|
||||
|
||||
## Docs
|
||||
- **i18n**: add Spanish, French, and Brazilian Portuguese README translations
|
||||
|
||||
## Security
|
||||
- **Real IP**: `x-9r-real-ip` and the Host fallback were trusted from
|
||||
client-controlled headers whenever `custom-server.js` was not in the request
|
||||
path (`npm run start`, `start:bun`), letting a remote caller pose as local to
|
||||
skip API key auth and reach `LOCAL_ONLY_PATHS` (`/api/mcp/*`,
|
||||
`/api/tunnel/enable`, `/api/auth/reset-password`). The server now stamps a
|
||||
per-process `x-9r-peer-token` on every request it sanitizes and only trusts
|
||||
`x-9r-real-ip` behind it — falling back to Host in development and failing
|
||||
closed in production (GHSA-pjm4-8fpg-f9p6). Also fixes IPv6 loopback
|
||||
detection (`::1`, `::ffff:127.0.0.1`) and routes `npm run start` /
|
||||
`start:bun` through `custom-server.js`
|
||||
- **Search**: `resolveBaseUrl()` rejects client-supplied non-public baseUrls
|
||||
(SSRF guard on `/v1/search`)
|
||||
- **Login**: fresh-install remote login with the default password returns 403
|
||||
without issuing a JWT
|
||||
- **Usage**: `/api/usage/request-details` redacts request/response payloads
|
||||
|
||||
# v0.5.50 (2026-08-05)
|
||||
|
||||
## Features
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ COPY --from=builder /app/src/mitm ./src/mitm
|
|||
COPY --from=builder /app/node_modules/node-forge ./node_modules/node-forge
|
||||
# Ensure `next` is available at runtime in case tracing did not include it.
|
||||
COPY --from=builder /app/node_modules/next ./node_modules/next
|
||||
# sql.js loads dist/sql-wasm.wasm by path at runtime; tracing only follows JS imports,
|
||||
# so the last-resort DB driver would abort with ENOENT on the missing binary.
|
||||
COPY --from=builder /app/node_modules/sql.js ./node_modules/sql.js
|
||||
|
||||
RUN mkdir -p /app/data && chown -R node:node /app && \
|
||||
mkdir -p /app/data-home && chown node:node /app/data-home && \
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
[🚀 Quick Start](#-quick-start) • [💡 Features](#-key-features) • [📖 Setup](#-setup-guide) • [🌐 Website](https://9router.com)
|
||||
|
||||
[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md)
|
||||
[🇧🇷 Português (Brasil)](./i18n/README.pt-BR.md) • [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) • [🇷🇺 Русский](./i18n/README.ru.md) • [🇹🇭 ไทย](./i18n/README.th.md) • [🇮🇷 فارسی](./i18n/README.fa_IR.md) • [🇮🇩 Indonesia](./i18n/README.id-ID.md) • [🇪🇸 Español](./i18n/README.es.md) • [🇫🇷 Français](./i18n/README.fr.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "9router",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"description": "9Router CLI - Start and manage 9Router server",
|
||||
"bin": {
|
||||
"9router": "./cli.js"
|
||||
|
|
|
|||
|
|
@ -216,7 +216,9 @@ function buildCliPackage() {
|
|||
fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js"));
|
||||
console.log("✅ Copied custom-server.js\n");
|
||||
} else {
|
||||
console.warn("⚠️ custom-server.js not found — server will run without real-IP injection\n");
|
||||
console.error("❌ custom-server.js not found — without it no request can be proven local,");
|
||||
console.error(" so the packaged CLI would demand an API key for its own dashboard and /v1.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules.
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ const PROVIDER_MODELS = {
|
|||
{ id: "glm-4.7" },
|
||||
],
|
||||
ag: [
|
||||
{ id: "gemini-3.7-flash-high" },
|
||||
{ id: "gemini-3.7-flash-medium" },
|
||||
{ id: "gemini-3.7-flash-low" },
|
||||
{ id: "gemini-3.6-flash-high" },
|
||||
{ id: "gemini-3.6-flash-medium" },
|
||||
{ id: "gemini-3.6-flash-low" },
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
const http = require("http");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { pathToFileURL } = require("url");
|
||||
|
||||
const origCreate = http.createServer.bind(http);
|
||||
|
||||
// Per-process secret proving x-9r-real-ip was stamped below rather than sent by the client.
|
||||
// A bare `next start` / `next dev` never loads this file, so it cannot produce a matching
|
||||
// header even though the env var is inherited by child processes. Named like x-9r-cli-token
|
||||
// so the request-detail header sanitizer redacts it too.
|
||||
const PEER_TOKEN = crypto.randomBytes(24).toString("hex");
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
|
||||
let backgroundRefreshStarted = false;
|
||||
|
||||
function startBackgroundTokenRefreshFromCustomServer() {
|
||||
|
|
@ -57,7 +66,9 @@ http.createServer = (...args) => {
|
|||
delete req.headers["x-9r-real-ip"];
|
||||
delete req.headers["x-forwarded-for"];
|
||||
delete req.headers["x-9r-via-proxy"];
|
||||
delete req.headers["x-9r-peer-token"];
|
||||
req.headers["x-9r-real-ip"] = ip;
|
||||
req.headers["x-9r-peer-token"] = PEER_TOKEN;
|
||||
if (viaProxy) req.headers["x-9r-via-proxy"] = "1";
|
||||
return handler(req, res);
|
||||
};
|
||||
|
|
@ -114,4 +125,15 @@ http.createServer = (...args) => {
|
|||
return server;
|
||||
};
|
||||
|
||||
if (require.main === module) require("./server.js");
|
||||
if (require.main === module) {
|
||||
const standalone = path.join(__dirname, "server.js");
|
||||
if (fs.existsSync(standalone)) {
|
||||
require(standalone);
|
||||
} else {
|
||||
// Repo checkout has no standalone build next to us. `next start` builds its HTTP
|
||||
// server in-process, so the wrapper above still sanitizes every request.
|
||||
const nextBin = require.resolve("next/dist/bin/next");
|
||||
process.argv = [process.argv[0], nextBin, "start", ...process.argv.slice(2)];
|
||||
require(nextBin);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1445
i18n/README.es.md
Normal file
1445
i18n/README.es.md
Normal file
File diff suppressed because it is too large
Load diff
1445
i18n/README.fr.md
Normal file
1445
i18n/README.fr.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js";
|
|||
import REGISTRY from "../providers/registry/index.js";
|
||||
// PROVIDER_MODELS now built from providers/registry (transport + models co-located)
|
||||
import { PROVIDER_MODELS } from "../providers/index.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
|
||||
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js";
|
||||
export { PROVIDER_MODELS };
|
||||
|
||||
|
|
@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, modelId) {
|
|||
return modelTargetFormat(findModel(models, modelId, aliasOrId));
|
||||
}
|
||||
|
||||
// Declared upstream formats for a model (registry `supportedFormats`). Drives the
|
||||
// per-model guard on the sourceFormat-matched transport; null when undeclared.
|
||||
export function getModelSupportedFormats(aliasOrId, modelId) {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return null;
|
||||
return modelSupportedFormats(findModel(models, modelId, aliasOrId));
|
||||
}
|
||||
|
||||
export function getModelType(aliasOrId, modelId) {
|
||||
const models = PROVIDER_MODELS[aliasOrId];
|
||||
if (!models) return null;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js";
|
|||
import { CursorExecutor } from "./cursor.js";
|
||||
import { VertexExecutor } from "./vertex.js";
|
||||
import { OpenCodeExecutor } from "./opencode.js";
|
||||
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
import { GrokWebExecutor } from "./grok-web.js";
|
||||
import { GrokCliExecutor } from "./grok-cli.js";
|
||||
import { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
|
|
@ -41,7 +40,6 @@ const executors = {
|
|||
vertex: new VertexExecutor("vertex"),
|
||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||
opencode: new OpenCodeExecutor(),
|
||||
"opencode-go": new OpenCodeGoExecutor(),
|
||||
"grok-web": new GrokWebExecutor(),
|
||||
"grok-cli": new GrokCliExecutor(),
|
||||
gcli: new GrokCliExecutor(), // Alias
|
||||
|
|
@ -86,7 +84,6 @@ export { CursorExecutor } from "./cursor.js";
|
|||
export { VertexExecutor } from "./vertex.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
export { OpenCodeExecutor } from "./opencode.js";
|
||||
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
export { GrokWebExecutor } from "./grok-web.js";
|
||||
export { GrokCliExecutor } from "./grok-cli.js";
|
||||
export { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
||||
|
||||
// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth)
|
||||
const MESSAGES_FORMAT_MODELS = new Set([
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
]);
|
||||
|
||||
const BASE = "https://opencode.ai/zen/go/v1";
|
||||
|
||||
export class OpenCodeGoExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("opencode-go", PROVIDERS["opencode-go"]);
|
||||
}
|
||||
|
||||
// buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here
|
||||
buildUrl(model) {
|
||||
this._lastModel = model;
|
||||
return MESSAGES_FORMAT_MODELS.has(model)
|
||||
? `${BASE}/messages`
|
||||
: `${BASE}/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
const key = credentials?.apiKey || credentials?.accessToken;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) {
|
||||
headers["x-api-key"] = key;
|
||||
headers["anthropic-version"] = ANTHROPIC_API_VERSION;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
}
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
return headers;
|
||||
}
|
||||
|
||||
transformRequest(model, body) {
|
||||
return injectReasoningContent({ provider: this.provider, model, body });
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/for
|
|||
import { createStreamController } from "../utils/streamHandler.js";
|
||||
import { refreshWithRetry } from "../services/tokenRefresh.js";
|
||||
import { createRequestLogger } from "../utils/requestLogger.js";
|
||||
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
|
||||
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js";
|
||||
|
|
@ -78,10 +78,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, model);
|
||||
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
|
||||
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation.
|
||||
// Per-model guard: only use the transport when the model declares support for that
|
||||
// sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do
|
||||
// /chat/completions), so without this guard a claude-format request would wrongly
|
||||
// route kimi to /messages.
|
||||
const modelSupportedFormats = getModelSupportedFormats(alias, model);
|
||||
const runtimeTransport = resolveTransport(provider, sourceFormat);
|
||||
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials);
|
||||
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
|
||||
// Per-model guard: when a model declares supportedFormats, only use the
|
||||
// sourceFormat-matched transport if that format is declared (opencode-go models
|
||||
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
|
||||
// upstream default (use the transport), preserving behavior for glm/deepseek/...
|
||||
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
|
||||
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
|
||||
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
|
||||
const stripList = getModelStrip(alias, model);
|
||||
const upstreamModel = getModelUpstreamId(alias, model);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,13 +44,14 @@ export function extractUsageFromResponse(responseBody) {
|
|||
};
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata) {
|
||||
// Gemini format. Antigravity / gemini-cli wrap the payload in { response: {...} }.
|
||||
const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata;
|
||||
if (usageMetadata) {
|
||||
return {
|
||||
prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: responseBody.usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: responseBody.usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: responseBody.usageMetadata.thoughtsTokenCount || 0
|
||||
prompt_tokens: usageMetadata.promptTokenCount || 0,
|
||||
completion_tokens: usageMetadata.candidatesTokenCount || 0,
|
||||
cached_tokens: usageMetadata.cachedContentTokenCount || 0,
|
||||
reasoning_tokens: usageMetadata.thoughtsTokenCount || 0
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,25 @@ async function huggingface({ baseUrl, apiKey, text, modelId }) {
|
|||
return responseToBase64(res, "wav");
|
||||
}
|
||||
|
||||
// Fish Audio: model travels in an HTTP header, the voice is a reference_id, returns binary
|
||||
async function fishAudio({ baseUrl, apiKey, text, modelId, voiceId }) {
|
||||
const res = await fetch(baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"model": modelId || "s2.1-pro-free",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
format: "mp3",
|
||||
...(voiceId ? { reference_id: voiceId } : {}),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) await throwUpstreamError(res);
|
||||
return responseToBase64(res, "mp3");
|
||||
}
|
||||
|
||||
// Inworld: Basic auth, JSON { audioContent }
|
||||
async function inworld({ baseUrl, apiKey, text, modelId, voiceId }) {
|
||||
const res = await fetch(baseUrl, {
|
||||
|
|
@ -166,4 +185,5 @@ export const FORMAT_HANDLERS = {
|
|||
tortoise,
|
||||
openai: openaiCompat,
|
||||
"minimax-tts": minimaxTts,
|
||||
"fish-audio": fishAudio,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ export const PATTERN_CAPABILITIES = [
|
|||
|
||||
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
|
||||
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
|
||||
{ pattern: "*gemini-3.7*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
|
||||
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
|
||||
|
|
|
|||
|
|
@ -38,3 +38,11 @@ export function modelStrip(model) {
|
|||
export function modelTargetFormat(model) {
|
||||
return model?.targetFormat || MODEL_DEFAULTS.targetFormat;
|
||||
}
|
||||
|
||||
// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the
|
||||
// sourceFormat-matched transport for multi-endpoint providers whose models differ
|
||||
// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen
|
||||
// also do /messages, deepseek also does /responses).
|
||||
export function modelSupportedFormats(model) {
|
||||
return model?.supportedFormats || null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ export const MODEL_PRICING = {
|
|||
"o1-mini": { input: 3.00, output: 12.00, cached: 1.50, reasoning: 18.00, cache_creation: 3.00 },
|
||||
|
||||
// === Gemini ===
|
||||
"gemini-3.7-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.7-flash-low": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash-high": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
"gemini-3.6-flash-medium": { input: 1.50, output: 7.50, cached: 0.15, reasoning: 11.25, cache_creation: 1.875 },
|
||||
|
|
|
|||
35
open-sse/providers/registry/alitp-intl.js
Normal file
35
open-sse/providers/registry/alitp-intl.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// Token Plan — credit subscription keys on token-plan.<region>.maas.aliyuncs.com.
|
||||
// Fourth Alibaba key type: Coding Plan (alicode/alicode-intl) and Model Studio
|
||||
// (alims-intl) both reject these keys, and they reject Model Studio keys back.
|
||||
// Singapore is the only region that serves the plan; eu-central-1 answers
|
||||
// IllegalEndpoint. The Anthropic surface (/apps/anthropic/v1/messages) is not
|
||||
// authorized for this plan, so OpenAI-compatible mode is the only transport.
|
||||
export default {
|
||||
id: "alitp-intl",
|
||||
priority: 11,
|
||||
alias: "alitp-intl",
|
||||
display: {
|
||||
name: "Alibaba Token Plan",
|
||||
icon: "cloud",
|
||||
color: "#FF6A00",
|
||||
textIcon: "ATP",
|
||||
website: "https://www.alibabacloud.com/campaign/ai-landing-page-token",
|
||||
notice: {
|
||||
apiKeyUrl: "https://modelstudio.console.alibabacloud.com/?apiKey=1",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
headers: {},
|
||||
quirks: { preserveCacheControl: true },
|
||||
},
|
||||
models: [
|
||||
{ id: "qwen3.8-max-preview", name: "Qwen3.8 Max Preview" },
|
||||
{ id: "qwen3.7-max", name: "Qwen3.7 Max" },
|
||||
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus" },
|
||||
{ id: "qwen3.6-flash", name: "Qwen3.6 Flash" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
],
|
||||
};
|
||||
|
|
@ -45,6 +45,9 @@ export default {
|
|||
clientSecret: "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",
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", upstreamModelId: "gemini-3.7-flash-tiered(high)" },
|
||||
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", upstreamModelId: "gemini-3.7-flash-tiered(medium)" },
|
||||
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", upstreamModelId: "gemini-3.7-flash-tiered(low)" },
|
||||
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", upstreamModelId: "gemini-3.6-flash-tiered(high)" },
|
||||
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", upstreamModelId: "gemini-3.6-flash-tiered(medium)" },
|
||||
{ id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)", upstreamModelId: "gemini-3.6-flash-tiered(low)" },
|
||||
|
|
|
|||
31
open-sse/providers/registry/fish-audio.js
Normal file
31
open-sse/providers/registry/fish-audio.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Fish Audio TTS — the model id travels in an HTTP `model` header rather than the
|
||||
// JSON body, and the voice is a reference_id (a cloned or preset voice model).
|
||||
export default {
|
||||
id: "fish-audio",
|
||||
alias: "fish",
|
||||
display: {
|
||||
name: "Fish Audio",
|
||||
icon: "record_voice_over",
|
||||
color: "#1E9BF0",
|
||||
textIcon: "FA",
|
||||
website: "https://fish.audio",
|
||||
notice: {
|
||||
apiKeyUrl: "https://fish.audio/app/api-keys/",
|
||||
},
|
||||
},
|
||||
category: "apikey",
|
||||
authType: "apikey",
|
||||
serviceKinds: ["tts"],
|
||||
ttsConfig: {
|
||||
baseUrl: "https://api.fish.audio/v1/tts",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
format: "fish-audio",
|
||||
models: [
|
||||
{ id: "s2.1-pro-free", name: "S2.1 Pro Free" },
|
||||
{ id: "s2.1-pro", name: "S2.1 Pro" },
|
||||
{ id: "s2-pro", name: "S2 Pro" },
|
||||
{ id: "s1", name: "S1" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -36,6 +36,7 @@ export default {
|
|||
},
|
||||
},
|
||||
models: [
|
||||
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" },
|
||||
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash" },
|
||||
{ id: "gemini-3.5-flash-lite", name: "Gemini 3.5 Flash Lite" },
|
||||
{ id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" },
|
||||
|
|
|
|||
|
|
@ -119,6 +119,8 @@ import p116 from "./tokenrouter.js";
|
|||
import p117 from "./selfhosted-stt.js";
|
||||
import p118 from "./selfhosted-tts.js";
|
||||
import p119 from "./selfhosted-embedding.js";
|
||||
import p120 from "./fish-audio.js";
|
||||
import p121 from "./alitp-intl.js";
|
||||
|
||||
export default [
|
||||
p0,
|
||||
|
|
@ -239,4 +241,6 @@ export default [
|
|||
p117,
|
||||
p118,
|
||||
p119,
|
||||
p120,
|
||||
p121,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -22,20 +22,28 @@ export default {
|
|||
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
headers: {},
|
||||
},
|
||||
// Multi-endpoint: pick the transport matching the client sourceFormat to skip
|
||||
// translation. Guarded per-model by `supportedFormats` (see chatCore) because
|
||||
// opencode-go models differ in endpoint support.
|
||||
transports: [
|
||||
{ format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
{ format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } },
|
||||
{ format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } },
|
||||
],
|
||||
models: [
|
||||
{ id: "glm-5.2", name: "GLM 5.2" },
|
||||
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6" },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5" },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" },
|
||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" },
|
||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" },
|
||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" },
|
||||
{ id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
|
||||
{ id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
|
||||
{ id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
|
||||
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
|
||||
{ id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
|
||||
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
|
||||
{ id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
|
||||
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -161,6 +161,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
|||
if (data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3.7-flash-high',
|
||||
'gemini-3.7-flash-medium',
|
||||
'gemini-3.7-flash-low',
|
||||
'gemini-3.6-flash-high',
|
||||
'gemini-3.6-flash-medium',
|
||||
'gemini-3.6-flash-low',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "9router-app",
|
||||
"version": "0.5.50",
|
||||
"version": "0.5.55",
|
||||
"description": "9Router web dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
|
@ -9,10 +9,10 @@
|
|||
"build": "next build --webpack",
|
||||
"postbuild": "node scripts/copy-standalone-assets.mjs",
|
||||
"postbuild:bun": "node scripts/copy-standalone-assets.mjs",
|
||||
"start": "next start --port 20127",
|
||||
"start": "node custom-server.js --port 20127",
|
||||
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
||||
"build:bun": "bun --bun next build --webpack",
|
||||
"start:bun": "bun ./.next/standalone/server.js",
|
||||
"start:bun": "bun ./.next/standalone/custom-server.js",
|
||||
"cli:pack": "npm --prefix cli run pack:cli",
|
||||
"cli:publish": "npm --prefix cli run publish:cli"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ export function copyStandaloneAssets({ projectRoot = process.cwd(), distDir = pr
|
|||
cpSync(publicSource, publicDestination, { recursive: true, force: true });
|
||||
console.log(`[standalone-assets] Copied public assets to ${publicDestination}`);
|
||||
}
|
||||
|
||||
// Without it beside server.js the standalone build serves requests unsanitized.
|
||||
const serverWrapperSource = resolve(projectRoot, "custom-server.js");
|
||||
const serverWrapperDestination = resolve(standaloneDir, "custom-server.js");
|
||||
if (existsSync(serverWrapperSource)) {
|
||||
cpSync(serverWrapperSource, serverWrapperDestination, { force: true });
|
||||
console.log(`[standalone-assets] Copied custom-server.js to ${serverWrapperDestination}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(dirname(fileURLToPath(import.meta.url)), "copy-standalone-assets.mjs")) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
|||
import { getSettings, validateApiKey } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { verifyDashboardAuthToken } from "@/lib/auth/dashboardSession";
|
||||
import { hasTrustedPeerHeaders } from "@/lib/auth/trustedPeer";
|
||||
|
||||
const CLI_TOKEN_HEADER = "x-9r-cli-token";
|
||||
const CLI_TOKEN_SALT = "9r-cli-auth";
|
||||
|
|
@ -87,24 +88,40 @@ const LOCAL_ONLY_PATHS = [
|
|||
|
||||
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
|
||||
// Accepts a Host header, a URL hostname or a raw socket address. Splitting on the first
|
||||
// colon only works for IPv4 and would reduce every IPv6 form to "", so a dual-stack
|
||||
// listener handing back ::ffff:127.0.0.1 would not read as loopback.
|
||||
function isLoopbackHostname(h) {
|
||||
if (!h) return false;
|
||||
const name = h.split(":")[0].replace(/^\[|\]$/g, "").toLowerCase();
|
||||
let name = String(h).trim().toLowerCase();
|
||||
if (name.startsWith("[")) {
|
||||
const end = name.indexOf("]");
|
||||
if (end === -1) return false;
|
||||
name = name.slice(1, end);
|
||||
} else if (name.indexOf(":") !== -1 && name.indexOf(":") === name.lastIndexOf(":")) {
|
||||
name = name.slice(0, name.indexOf(":"));
|
||||
}
|
||||
if (name.startsWith("::ffff:")) name = name.slice(7);
|
||||
return LOOPBACK_HOSTS.has(name);
|
||||
}
|
||||
|
||||
function isLoopbackPeer(request) {
|
||||
if (hasTrustedPeerHeaders(request)) {
|
||||
return isLoopbackHostname(request.headers.get("x-9r-real-ip"));
|
||||
}
|
||||
// Bare `next dev` forks its server, so the wrapper never loads and no peer address
|
||||
// reaches us. Host is spoofable, so this stays confined to development.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return isLoopbackHostname(request.headers.get("host"));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isLocalRequest(request) {
|
||||
// Stamped by custom-server.js when forwarding headers exist: request came through
|
||||
// a reverse proxy, so the loopback socket is the proxy hop, not the end-user.
|
||||
if (request.headers.get("x-9r-via-proxy")) return false;
|
||||
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) {
|
||||
if (!isLoopbackHostname(realIp)) return false;
|
||||
} else if (!isLoopbackHostname(request.headers.get("host"))) {
|
||||
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
|
||||
return false;
|
||||
}
|
||||
if (!isLoopbackPeer(request)) return false;
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// In-memory progressive lockout for dashboard login. Resets on process restart.
|
||||
import { hasTrustedPeerHeaders } from "./trustedPeer.js";
|
||||
|
||||
const MAX_FAILS_BEFORE_LOCK = 5;
|
||||
const LOCK_STEPS_MS = [30_000, 120_000, 600_000, 1_800_000]; // 30s, 2m, 10m, 30m
|
||||
|
|
@ -46,9 +47,12 @@ export function recordSuccess(ip) {
|
|||
}
|
||||
|
||||
export function getClientIp(request) {
|
||||
// Trusted: set from TCP socket by custom-server.js (client cannot spoof).
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) return realIp;
|
||||
// Trusted only when custom-server.js proves it stamped the header from the TCP socket;
|
||||
// otherwise a client could rotate the value to escape its own lockout bucket.
|
||||
if (hasTrustedPeerHeaders(request)) {
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) return realIp;
|
||||
}
|
||||
// Behind a trusted reverse proxy that overwrites XFF with the real client IP.
|
||||
if (process.env.TRUST_PROXY === "true") {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
|
|
|
|||
7
src/lib/auth/trustedPeer.js
Normal file
7
src/lib/auth/trustedPeer.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// x-9r-real-ip is only trustworthy when custom-server.js stamped it from the TCP socket.
|
||||
// It proves that by echoing the per-process secret it generated at boot, which a client
|
||||
// cannot guess. Without the proof the header is just attacker-supplied input.
|
||||
export function hasTrustedPeerHeaders(request) {
|
||||
const token = process.env.NINEROUTER_PEER_TOKEN;
|
||||
return Boolean(token) && request.headers.get("x-9r-peer-token") === token;
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ function sanitizeHeaders(headers) {
|
|||
return sanitized;
|
||||
}
|
||||
|
||||
export const __test__ = { sanitizeHeaders };
|
||||
|
||||
function generateDetailId(model) {
|
||||
const timestamp = new Date().toISOString();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ const MODEL_SYNONYMS = {
|
|||
"gemini-3.5-flash-high": "gemini-3-flash-agent",
|
||||
"gemini-3.5-flash-medium": "gemini-3.5-flash-low",
|
||||
"gemini-3.5-flash-extra-low": "gemini-3.5-flash-extra-low",
|
||||
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
|
||||
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
|
||||
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
|
||||
"gemini-3.1-pro-high": "gemini-pro-agent",
|
||||
"gemini-3-pro-high": "gemini-pro-agent",
|
||||
"gemini-3-pro-low": "gemini-3.1-pro-low",
|
||||
|
|
@ -131,13 +134,15 @@ function extractModel(url, body) {
|
|||
return parsed.conversationState.currentMessage?.userInputMessage?.modelId || null;
|
||||
}
|
||||
const model = urlModel || parsed.model || null;
|
||||
if (String(model).replace(/^models\//, "") === "gemini-3.6-flash-tiered") {
|
||||
const cleanModelName = String(model).replace(/^models\//, "");
|
||||
if (cleanModelName === "gemini-3.6-flash-tiered" || cleanModelName === "gemini-3.7-flash-tiered") {
|
||||
const ver = cleanModelName.includes("3.7") ? "3.7" : "3.6";
|
||||
const rawLevel = parsed.request?.generationConfig?.thinkingConfig?.thinkingLevel
|
||||
|| parsed.generationConfig?.thinkingConfig?.thinkingLevel;
|
||||
const level = ["high", "medium", "low"].includes(String(rawLevel).toLowerCase())
|
||||
? String(rawLevel).toLowerCase()
|
||||
: "medium";
|
||||
return `gemini-3.6-flash-${level}`;
|
||||
return `gemini-${ver}-flash-${level}`;
|
||||
}
|
||||
return model;
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const MITM_TOOLS = {
|
|||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
mitmDomain: "daily-cloudcode-pa.googleapis.com",
|
||||
modelAliases: ["gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
modelAliases: ["gemini-3.7-flash-high", "gemini-3.7-flash-medium", "gemini-3.7-flash-low", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", "gemini-3.5-flash-low", "gemini-3-flash-agent", "gemini-3.5-flash-extra-low", "gemini-3.1-pro-low", "gemini-pro-agent", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", "gemini-3-flash"],
|
||||
defaultModels: [
|
||||
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", alias: "gemini-3.6-flash-high" },
|
||||
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", alias: "gemini-3.6-flash-medium" },
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@
|
|||
"alicode": "alicode",
|
||||
"alicode-intl": "alicode-intl",
|
||||
"alims-intl": "alims-intl",
|
||||
"alitp-intl": "alitp-intl",
|
||||
"anthropic": "anthropic",
|
||||
"antigravity": "ag",
|
||||
"api-airforce": "af",
|
||||
|
|
@ -206,6 +207,7 @@
|
|||
"alicode",
|
||||
"alicode-intl",
|
||||
"alims-intl",
|
||||
"alitp-intl",
|
||||
"anthropic",
|
||||
"assemblyai",
|
||||
"black-forest-labs",
|
||||
|
|
|
|||
|
|
@ -708,7 +708,37 @@
|
|||
"opencode-go": {
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
"headers": {},
|
||||
"format": "openai"
|
||||
"format": "openai",
|
||||
"transports": [
|
||||
{
|
||||
"format": "openai",
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "Authorization",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"format": "claude",
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1/messages",
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "x-api-key",
|
||||
"scheme": "raw",
|
||||
"anthropicVersion": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"format": "openai-responses",
|
||||
"baseUrl": "https://opencode.ai/zen/go/v1/responses",
|
||||
"auth": {
|
||||
"combined": true,
|
||||
"header": "Authorization",
|
||||
"scheme": "bearer"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"opencode": {
|
||||
"baseUrl": "https://opencode.ai",
|
||||
|
|
@ -968,7 +998,15 @@
|
|||
"tokenrouter": {
|
||||
"baseUrl": "https://api.tokenrouter.com/v1/chat/completions",
|
||||
"validateUrl": "https://api.tokenrouter.com/v1/models",
|
||||
"thinkingFormat": "openai",
|
||||
"thinkingFormat": "tokenrouter",
|
||||
"format": "openai"
|
||||
},
|
||||
"alitp-intl": {
|
||||
"baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
"headers": {},
|
||||
"quirks": {
|
||||
"preserveCacheControl": true
|
||||
},
|
||||
"format": "openai"
|
||||
}
|
||||
}
|
||||
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
45
tests/unit/alibaba-token-plan-provider.test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { PROVIDERS, PROVIDER_MODELS } from "../../open-sse/providers/index.js";
|
||||
|
||||
describe("Alibaba Token Plan provider", () => {
|
||||
const entry = REGISTRY.find((e) => e.id === "alitp-intl");
|
||||
|
||||
it("is registered as an OpenAI-compatible apikey provider", () => {
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.category).toBe("apikey");
|
||||
expect(PROVIDERS["alitp-intl"]).toBeDefined();
|
||||
expect(PROVIDERS["alitp-intl"].format).toBe("openai");
|
||||
});
|
||||
|
||||
it("targets the Singapore Token Plan host in compatible mode", () => {
|
||||
// eu-central-1 answers IllegalEndpoint; the plan is Singapore-only.
|
||||
expect(PROVIDERS["alitp-intl"].baseUrl).toBe(
|
||||
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide with the other three Alibaba key types", () => {
|
||||
const hosts = ["alicode", "alicode-intl", "alims-intl", "alitp-intl"]
|
||||
.map((id) => new URL(PROVIDERS[id].baseUrl).host);
|
||||
expect(new Set(hosts).size).toBe(hosts.length);
|
||||
});
|
||||
|
||||
it("exposes the models the plan actually serves", () => {
|
||||
const ids = (PROVIDER_MODELS["alitp-intl"] || []).map((m) => m.id);
|
||||
expect(ids).toEqual(expect.arrayContaining([
|
||||
"qwen3.8-max-preview",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-flash",
|
||||
"glm-5.2",
|
||||
"deepseek-v4-pro",
|
||||
]));
|
||||
});
|
||||
|
||||
it("keeps every registry id unique after adding the provider", () => {
|
||||
const ids = REGISTRY.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
54
tests/unit/antigravity-nonstream-usage-3260.test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/usageDb.js", () => ({
|
||||
appendRequestLog: vi.fn(async () => {}),
|
||||
saveRequestDetail: vi.fn(async () => {}),
|
||||
saveRequestUsage: vi.fn(async () => {})
|
||||
}));
|
||||
|
||||
const { extractUsageFromResponse } = await import("../../open-sse/handlers/chatCore/requestDetail.js");
|
||||
|
||||
const USAGE_METADATA = {
|
||||
promptTokenCount: 1234,
|
||||
candidatesTokenCount: 56,
|
||||
cachedContentTokenCount: 78,
|
||||
thoughtsTokenCount: 90,
|
||||
};
|
||||
|
||||
const EXPECTED = {
|
||||
prompt_tokens: 1234,
|
||||
completion_tokens: 56,
|
||||
cached_tokens: 78,
|
||||
reasoning_tokens: 90,
|
||||
};
|
||||
|
||||
describe("#3260 non-streaming usage extraction for enveloped Gemini responses", () => {
|
||||
it("reads usageMetadata out of the antigravity { response } envelope", () => {
|
||||
expect(extractUsageFromResponse({ response: { usageMetadata: USAGE_METADATA } })).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it("still reads a top-level usageMetadata", () => {
|
||||
expect(extractUsageFromResponse({ usageMetadata: USAGE_METADATA })).toEqual(EXPECTED);
|
||||
});
|
||||
|
||||
it("prefers the top-level metadata when both are present", () => {
|
||||
const enveloped = { ...USAGE_METADATA, promptTokenCount: 1 };
|
||||
const out = extractUsageFromResponse({
|
||||
usageMetadata: USAGE_METADATA,
|
||||
response: { usageMetadata: enveloped },
|
||||
});
|
||||
expect(out.prompt_tokens).toBe(1234);
|
||||
});
|
||||
|
||||
it("leaves the OpenAI and Claude shapes alone", () => {
|
||||
expect(extractUsageFromResponse({ usage: { prompt_tokens: 10, completion_tokens: 2 } }))
|
||||
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
|
||||
expect(extractUsageFromResponse({ usage: { input_tokens: 10, output_tokens: 2 } }))
|
||||
.toMatchObject({ prompt_tokens: 10, completion_tokens: 2 });
|
||||
});
|
||||
|
||||
it("returns null when there is no usage anywhere", () => {
|
||||
expect(extractUsageFromResponse({ response: { candidates: [] } })).toBeNull();
|
||||
expect(extractUsageFromResponse(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
61
tests/unit/antigravity-quota-gemini-3.7.test.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const proxyAwareFetch = vi.fn(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => url.includes(":loadCodeAssist")
|
||||
? { cloudaicompanionProject: "project-1", currentTier: { name: "Pro" } }
|
||||
: {
|
||||
models: {
|
||||
"gemini-3.7-flash-high": {
|
||||
displayName: "Gemini 3.7 Flash (High)",
|
||||
quotaInfo: { remainingFraction: 0.85, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.7-flash-medium": {
|
||||
displayName: "Gemini 3.7 Flash (Medium)",
|
||||
quotaInfo: { remainingFraction: 0.6, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"gemini-3.7-flash-low": {
|
||||
displayName: "Gemini 3.7 Flash (Low)",
|
||||
quotaInfo: { remainingFraction: 0.35, resetTime: "2026-08-25T12:00:00Z" },
|
||||
},
|
||||
"internal-model": {
|
||||
displayName: "Internal",
|
||||
isInternal: true,
|
||||
quotaInfo: { remainingFraction: 0.5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
text: async () => "{}",
|
||||
}));
|
||||
|
||||
vi.mock("../../open-sse/utils/proxyFetch.js", () => ({
|
||||
proxyAwareFetch,
|
||||
}));
|
||||
|
||||
describe("Antigravity quota tracker: Gemini 3.7 Flash usage bars", () => {
|
||||
beforeEach(() => proxyAwareFetch.mockClear());
|
||||
|
||||
it("returns Gemini 3.7 Flash tier quotas so the dashboard can render usage bars", async () => {
|
||||
const { getAntigravityUsage } = await import("../../open-sse/services/usage/google.js");
|
||||
|
||||
const usage = await getAntigravityUsage("access-token", {});
|
||||
|
||||
expect(usage.quotas["gemini-3.7-flash-high"]).toMatchObject({
|
||||
used: 150,
|
||||
total: 1000,
|
||||
remainingPercentage: 85,
|
||||
displayName: "Gemini 3.7 Flash (High)",
|
||||
});
|
||||
expect(usage.quotas["gemini-3.7-flash-medium"]).toMatchObject({
|
||||
used: 400,
|
||||
total: 1000,
|
||||
remainingPercentage: 60,
|
||||
});
|
||||
expect(usage.quotas["gemini-3.7-flash-low"]).toMatchObject({
|
||||
used: 650,
|
||||
total: 1000,
|
||||
remainingPercentage: 35,
|
||||
});
|
||||
});
|
||||
});
|
||||
86
tests/unit/custom-server-peer-headers.test.js
Normal file
86
tests/unit/custom-server-peer-headers.test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// custom-server.js is the only thing that makes x-9r-real-ip trustworthy. Boot a real
|
||||
// HTTP server through it and confirm a client cannot smuggle its own peer headers in.
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import http from "node:http";
|
||||
import { __test__ as requestDetails } from "@/lib/db/repos/requestDetailsRepo.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
let server;
|
||||
let baseUrl;
|
||||
let seenHeaders;
|
||||
|
||||
beforeAll(async () => {
|
||||
require("../../custom-server.js");
|
||||
server = http.createServer((req, res) => {
|
||||
seenHeaders = req.headers;
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
async function get(headers = {}) {
|
||||
await fetch(baseUrl, { headers });
|
||||
return seenHeaders;
|
||||
}
|
||||
|
||||
describe("custom-server peer header sanitizing", () => {
|
||||
it("generates a peer trust token at boot", () => {
|
||||
expect(process.env.NINEROUTER_PEER_TOKEN).toMatch(/^[0-9a-f]{48}$/);
|
||||
});
|
||||
|
||||
it("replaces a client-supplied x-9r-real-ip with the socket address", async () => {
|
||||
const headers = await get({ "x-9r-real-ip": "203.0.113.55" });
|
||||
|
||||
expect(headers["x-9r-real-ip"]).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
||||
});
|
||||
|
||||
it("stamps the trust token so downstream can tell the wrapper ran", async () => {
|
||||
const headers = await get();
|
||||
|
||||
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
|
||||
});
|
||||
|
||||
it("drops a client-supplied peer trust token", async () => {
|
||||
const headers = await get({ "x-9r-peer-token": "forged-token" });
|
||||
|
||||
expect(headers["x-9r-peer-token"]).toBe(process.env.NINEROUTER_PEER_TOKEN);
|
||||
expect(headers["x-9r-peer-token"]).not.toBe("forged-token");
|
||||
});
|
||||
|
||||
it("drops a client-supplied x-9r-via-proxy marker", async () => {
|
||||
const headers = await get({ "x-9r-via-proxy": "1" });
|
||||
|
||||
expect(headers["x-9r-via-proxy"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks via-proxy and adopts the forwarded IP for a loopback proxy hop", async () => {
|
||||
const headers = await get({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" });
|
||||
|
||||
expect(headers["x-9r-via-proxy"]).toBe("1");
|
||||
expect(headers["x-9r-real-ip"]).toBe("203.0.113.9");
|
||||
expect(headers["x-forwarded-for"]).toBeUndefined();
|
||||
});
|
||||
|
||||
// chat.js snapshots every client header into the request detail. Anything that grants
|
||||
// access must not survive into a record the dashboard renders and cloud sync uploads.
|
||||
it("keeps the peer token out of persisted request details", () => {
|
||||
const sanitized = requestDetails.sanitizeHeaders({
|
||||
"x-9r-peer-token": "secret",
|
||||
"x-9r-cli-token": "secret",
|
||||
"authorization": "Bearer sk-x",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
});
|
||||
|
||||
expect(sanitized["x-9r-peer-token"]).toBeUndefined();
|
||||
expect(sanitized["x-9r-cli-token"]).toBeUndefined();
|
||||
expect(sanitized["authorization"]).toBeUndefined();
|
||||
expect(sanitized["x-9r-real-ip"]).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
|
|
@ -35,6 +35,8 @@ vi.mock("@/lib/auth/dashboardSession", () => ({
|
|||
|
||||
const { proxy, __test__ } = await import("../../src/dashboardGuard.js");
|
||||
|
||||
const PEER_TOKEN = "peer-token-fixture";
|
||||
|
||||
function request(pathname, headers = {}) {
|
||||
const normalizedHeaders = new Headers(headers);
|
||||
return {
|
||||
|
|
@ -45,9 +47,16 @@ function request(pathname, headers = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
// A request that actually came through custom-server.js: peer IP stamped from the TCP
|
||||
// socket and proven by the per-process secret.
|
||||
function localRequest(pathname, headers = {}) {
|
||||
return request(pathname, { "x-9r-peer-token": PEER_TOKEN, "x-9r-real-ip": "127.0.0.1", ...headers });
|
||||
}
|
||||
|
||||
describe("dashboard guard public LLM API access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
|
|
@ -55,14 +64,14 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", { host: "localhost:20128" }));
|
||||
const response = await proxy(localRequest("/v1/chat/completions", { host: "localhost:20128" }));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
||||
host: "localhost",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
}));
|
||||
|
|
@ -72,7 +81,7 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback peer IP regardless of Host", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
const response = await proxy(localRequest("/v1/chat/completions", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
|
@ -89,7 +98,7 @@ describe("dashboard guard public LLM API access", () => {
|
|||
});
|
||||
|
||||
it("allows loopback rewritten public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/api/v1/chat/completions", { host: "localhost:20128" }));
|
||||
const response = await proxy(localRequest("/api/v1/chat/completions", { host: "localhost:20128" }));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
|
|
@ -191,6 +200,7 @@ describe("dashboard guard public LLM API access", () => {
|
|||
describe("dashboard guard local-only access", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
|
|
@ -207,7 +217,7 @@ describe("dashboard guard local-only access", () => {
|
|||
});
|
||||
|
||||
it("rejects local-only route on loopback when requireLogin=true and no JWT", async () => {
|
||||
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||
const response = await proxy(localRequest("/api/mcp/filesystem/sse", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}));
|
||||
|
|
@ -219,7 +229,7 @@ describe("dashboard guard local-only access", () => {
|
|||
it("allows local-only route on loopback when requireLogin=false", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://localhost:20128",
|
||||
}));
|
||||
|
|
@ -240,7 +250,7 @@ describe("dashboard guard local-only access", () => {
|
|||
it("rejects local-only route when Origin is non-loopback (CSRF block)", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
const response = await proxy(request("/api/cli-tools/antigravity-mitm", {
|
||||
const response = await proxy(localRequest("/api/cli-tools/antigravity-mitm", {
|
||||
host: "localhost:20128",
|
||||
origin: "http://evil.example.com",
|
||||
}));
|
||||
|
|
|
|||
109
tests/unit/fish-audio-tts.test.js
Normal file
109
tests/unit/fish-audio-tts.test.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import REGISTRY from "../../open-sse/providers/registry/index.js";
|
||||
import { PROVIDER_MEDIA } from "../../open-sse/providers/index.js";
|
||||
import { FORMAT_HANDLERS } from "../../open-sse/handlers/ttsProviders/genericFormats.js";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
const AUDIO = new Uint8Array(256).fill(7);
|
||||
|
||||
function okResponse() {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "audio/mpeg" }),
|
||||
arrayBuffer: async () => AUDIO.buffer,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Fish Audio TTS provider", () => {
|
||||
const entry = REGISTRY.find((e) => e.id === "fish-audio");
|
||||
|
||||
it("is registered as a TTS-only apikey provider", () => {
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.category).toBe("apikey");
|
||||
expect(entry.serviceKinds).toEqual(["tts"]);
|
||||
expect(PROVIDER_MEDIA["fish-audio"]?.ttsConfig?.baseUrl).toBe("https://api.fish.audio/v1/tts");
|
||||
});
|
||||
|
||||
it("is visible to the generic dispatcher, which reads AI_PROVIDERS", () => {
|
||||
// synthesizeViaConfig() looks the provider up here, not in PROVIDER_MEDIA.
|
||||
expect(AI_PROVIDERS["fish-audio"]?.ttsConfig?.format).toBe("fish-audio");
|
||||
expect(typeof FORMAT_HANDLERS["fish-audio"]).toBe("function");
|
||||
});
|
||||
|
||||
it("exposes the four documented models", () => {
|
||||
const ids = (entry.ttsConfig.models || []).map((m) => m.id);
|
||||
expect(ids).toEqual(["s2.1-pro-free", "s2.1-pro", "s2-pro", "s1"]);
|
||||
});
|
||||
|
||||
it("keeps registry ids and aliases unique", () => {
|
||||
const ids = REGISTRY.map((e) => e.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
const aliases = REGISTRY.map((e) => e.alias).filter(Boolean);
|
||||
expect(new Set(aliases).size).toBe(aliases.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fish Audio TTS request shape", () => {
|
||||
const handler = FORMAT_HANDLERS["fish-audio"];
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn(async () => okResponse());
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
const callArgs = () => {
|
||||
const [url, init] = fetchMock.mock.calls.at(-1);
|
||||
return { url, init, body: JSON.parse(init.body) };
|
||||
};
|
||||
|
||||
it("sends the model as an HTTP header, not in the body", async () => {
|
||||
await handler({
|
||||
baseUrl: "https://api.fish.audio/v1/tts",
|
||||
apiKey: "sk-test",
|
||||
text: "xin chào",
|
||||
modelId: "s1",
|
||||
voiceId: "",
|
||||
});
|
||||
|
||||
const { url, init, body } = callArgs();
|
||||
expect(url).toBe("https://api.fish.audio/v1/tts");
|
||||
expect(init.headers.model).toBe("s1");
|
||||
expect(init.headers.Authorization).toBe("Bearer sk-test");
|
||||
expect(body).toEqual({ text: "xin chào", format: "mp3" });
|
||||
});
|
||||
|
||||
it("maps the voice onto reference_id, and omits it when unset", async () => {
|
||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "voice-abc" });
|
||||
expect(callArgs().body.reference_id).toBe("voice-abc");
|
||||
|
||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
|
||||
expect(callArgs().body).not.toHaveProperty("reference_id");
|
||||
});
|
||||
|
||||
it("defaults to the free model when none is given", async () => {
|
||||
await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "", voiceId: "" });
|
||||
expect(callArgs().init.headers.model).toBe("s2.1-pro-free");
|
||||
});
|
||||
|
||||
it("returns base64 audio with its format", async () => {
|
||||
const out = await handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" });
|
||||
expect(out.format).toBe("mp3");
|
||||
expect(typeof out.base64).toBe("string");
|
||||
expect(out.base64.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("surfaces the upstream error message", async () => {
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 402,
|
||||
text: async () => JSON.stringify({ message: "Insufficient credit" }),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
handler({ baseUrl: "u", apiKey: "k", text: "t", modelId: "s1", voiceId: "" }),
|
||||
).rejects.toThrow("Insufficient credit");
|
||||
});
|
||||
});
|
||||
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
36
tests/unit/gemini-3.7-antigravity.test.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
|
||||
import antigravityRegistry from "../../open-sse/providers/registry/antigravity.js";
|
||||
import geminiRegistry from "../../open-sse/providers/registry/gemini.js";
|
||||
import { MODEL_PRICING } from "../../open-sse/providers/pricing.js";
|
||||
|
||||
describe("Gemini 3.7 Flash Support & Config (#3286, #3281)", () => {
|
||||
it("registers gemini-3.7-flash tiered models in antigravity provider registry", () => {
|
||||
const agIds = antigravityRegistry.models.map(m => m.id);
|
||||
expect(agIds).toContain("gemini-3.7-flash-high");
|
||||
expect(agIds).toContain("gemini-3.7-flash-medium");
|
||||
expect(agIds).toContain("gemini-3.7-flash-low");
|
||||
expect(agIds).not.toContain("gemini-3.7-flash");
|
||||
});
|
||||
|
||||
it("registers gemini-3.7-flash in gemini provider registry", () => {
|
||||
const geminiIds = geminiRegistry.models.map(m => m.id);
|
||||
expect(geminiIds).toContain("gemini-3.7-flash");
|
||||
});
|
||||
|
||||
it("resolves capabilities correctly for gemini-3.7 models with official limits", () => {
|
||||
const caps = getCapabilitiesForModel("antigravity", "gemini-3.7-flash-high");
|
||||
expect(caps.vision).toBe(true);
|
||||
expect(caps.reasoning).toBe(true);
|
||||
expect(caps.thinkingFormat).toBe("gemini-level");
|
||||
expect(caps.contextWindow).toBe(1048576);
|
||||
expect(caps.maxOutput).toBe(65536);
|
||||
});
|
||||
|
||||
it("defines pricing matching gemini-3.6-flash baseline", () => {
|
||||
expect(MODEL_PRICING["gemini-3.7-flash"]).toEqual(MODEL_PRICING["gemini-3.6-flash"]);
|
||||
expect(MODEL_PRICING["gemini-3.7-flash-high"]).toEqual(MODEL_PRICING["gemini-3.6-flash-high"]);
|
||||
expect(MODEL_PRICING["gemini-3.7-flash-medium"]).toEqual(MODEL_PRICING["gemini-3.6-flash-medium"]);
|
||||
expect(MODEL_PRICING["gemini-3.7-flash-low"]).toEqual(MODEL_PRICING["gemini-3.6-flash-low"]);
|
||||
});
|
||||
});
|
||||
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
211
tests/unit/local-request-peer-trust-3294.test.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// GHSA-pjm4-8fpg-f9p6 (#3294): `next start` leaves custom-server.js out of the request
|
||||
// path, so x-9r-real-ip arrives straight from the client and a remote caller can claim to
|
||||
// be loopback. Host is spoofable the same way, so it cannot be the production fallback.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
nextResponse: Symbol("next"),
|
||||
jsonResponse: vi.fn((body, init) => ({ status: init?.status || 200, body })),
|
||||
getSettings: vi.fn(),
|
||||
validateApiKey: vi.fn(),
|
||||
getConsistentMachineId: vi.fn(),
|
||||
verifyDashboardAuthToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
next: vi.fn(() => mocks.nextResponse),
|
||||
json: mocks.jsonResponse,
|
||||
redirect: vi.fn((url) => ({ status: 307, url })),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/localDb", () => ({
|
||||
getSettings: mocks.getSettings,
|
||||
validateApiKey: mocks.validateApiKey,
|
||||
}));
|
||||
|
||||
vi.mock("@/shared/utils/machineId", () => ({
|
||||
getConsistentMachineId: mocks.getConsistentMachineId,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/dashboardSession", () => ({
|
||||
verifyDashboardAuthToken: mocks.verifyDashboardAuthToken,
|
||||
}));
|
||||
|
||||
const { proxy } = await import("../../src/dashboardGuard.js");
|
||||
const { getClientIp } = await import("../../src/lib/auth/loginLimiter.js");
|
||||
|
||||
const PEER_TOKEN = "peer-token-fixture";
|
||||
|
||||
function request(pathname, headers = {}) {
|
||||
return {
|
||||
nextUrl: { pathname, searchParams: new URL(`http://localhost${pathname}`).searchParams },
|
||||
headers: new Headers(headers),
|
||||
cookies: { get: vi.fn(() => undefined) },
|
||||
url: `http://localhost${pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
|
||||
describe("peer header trust", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
process.env.NODE_ENV = "production";
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: true });
|
||||
mocks.validateApiKey.mockResolvedValue(false);
|
||||
mocks.getConsistentMachineId.mockResolvedValue("cli-token");
|
||||
mocks.verifyDashboardAuthToken.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
delete process.env.NINEROUTER_PEER_TOKEN;
|
||||
});
|
||||
|
||||
it("rejects a spoofed loopback peer IP that carries no trust proof", async () => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "172.18.192.1:20140",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("rejects a spoofed loopback peer IP carrying a wrong trust token", async () => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "172.18.192.1:20140",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
"x-9r-peer-token": "guessed-token",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a spoofed loopback Host in production", async () => {
|
||||
const response = await proxy(request("/api/v1/models", { host: "localhost" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a spoofed loopback peer IP when the wrapper never booted", async () => {
|
||||
delete process.env.NINEROUTER_PEER_TOKEN;
|
||||
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "172.18.192.1:20140",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
"x-9r-peer-token": "any-token",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("keeps serving a genuinely local request stamped by the wrapper", async () => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
"x-9r-peer-token": PEER_TOKEN,
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A dual-stack listener reports loopback as ::ffff:127.0.0.1, which the old
|
||||
// split-on-first-colon check reduced to "".
|
||||
it.each(["::ffff:127.0.0.1", "::1", "[::1]", "127.0.0.1", "::FFFF:127.0.0.1"])(
|
||||
"treats %s as a loopback peer",
|
||||
async (peerIp) => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": peerIp,
|
||||
"x-9r-peer-token": PEER_TOKEN,
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(["::ffff:10.204.111.34", "2001:db8::1", "[2001:db8::1]", "10.204.111.34"])(
|
||||
"refuses %s as a peer",
|
||||
async (peerIp) => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": peerIp,
|
||||
"x-9r-peer-token": PEER_TOKEN,
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
);
|
||||
|
||||
it("still refuses a stamped non-loopback peer IP", async () => {
|
||||
const response = await proxy(request("/api/v1/models", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
"x-9r-peer-token": PEER_TOKEN,
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it("blocks spoofed local-only routes that would otherwise spawn processes", async () => {
|
||||
mocks.getSettings.mockResolvedValue({ requireLogin: false });
|
||||
|
||||
const response = await proxy(request("/api/mcp/filesystem/sse", {
|
||||
host: "172.18.192.1:20140",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe("Local only: CLI token required");
|
||||
});
|
||||
|
||||
it("accepts the legacy Host fallback only in development", async () => {
|
||||
process.env.NODE_ENV = "development";
|
||||
|
||||
const response = await proxy(request("/api/v1/models", { host: "localhost:20127" }));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login limiter client IP", () => {
|
||||
beforeEach(() => {
|
||||
process.env.NINEROUTER_PEER_TOKEN = PEER_TOKEN;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NINEROUTER_PEER_TOKEN;
|
||||
delete process.env.TRUST_PROXY;
|
||||
});
|
||||
|
||||
it("buckets spoofed peer IPs together so lockout cannot be rotated away", () => {
|
||||
const first = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "1.1.1.1" }));
|
||||
const second = getClientIp(request("/api/auth/login", { "x-9r-real-ip": "2.2.2.2" }));
|
||||
|
||||
expect(first).toBe("unknown");
|
||||
expect(second).toBe("unknown");
|
||||
});
|
||||
|
||||
it("keys on the stamped peer IP when the wrapper proved it", () => {
|
||||
const ip = getClientIp(request("/api/auth/login", {
|
||||
"x-9r-real-ip": "203.0.113.9",
|
||||
"x-9r-peer-token": PEER_TOKEN,
|
||||
}));
|
||||
|
||||
expect(ip).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("still honours TRUST_PROXY for operators fronting 9router with a reverse proxy", () => {
|
||||
process.env.TRUST_PROXY = "true";
|
||||
|
||||
const ip = getClientIp(request("/api/auth/login", { "x-forwarded-for": "198.51.100.7, 10.0.0.1" }));
|
||||
|
||||
expect(ip).toBe("198.51.100.7");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,71 +1,97 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
|
||||
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js";
|
||||
import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
|
||||
import { PROVIDERS } from "../../open-sse/config/providers.js";
|
||||
import { resolveTransport } from "../../open-sse/services/provider.js";
|
||||
|
||||
const CHAT_MODELS = [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
// OpenCode Go docs' endpoint table currently says kimi-k2.7, but its
|
||||
// config example and the live API use kimi-k2.7-code.
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"mimo-v2.5",
|
||||
"mimo-v2.5-pro",
|
||||
];
|
||||
// Chat-only models (no /messages, no /responses support on opencode-go)
|
||||
const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
|
||||
// Models that also expose the Anthropic /messages endpoint
|
||||
const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
|
||||
// Models that also expose the OpenAI /responses endpoint
|
||||
const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
|
||||
|
||||
const MESSAGES_MODELS = [
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
];
|
||||
// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
|
||||
// transport only when the model declares support for that sourceFormat.
|
||||
function pickTransport(provider, sourceFormat, alias, model) {
|
||||
const supported = getModelSupportedFormats(alias, model);
|
||||
const rt = resolveTransport(provider, sourceFormat);
|
||||
return supported?.includes(sourceFormat) ? rt : null;
|
||||
}
|
||||
|
||||
describe("OpenCode Go official model catalog", () => {
|
||||
it("matches the documented OpenCode Go model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id);
|
||||
|
||||
expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]);
|
||||
describe("OpenCode Go model catalog", () => {
|
||||
it("matches the documented model IDs", () => {
|
||||
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
|
||||
expect(ids).toEqual([
|
||||
"glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6",
|
||||
"deepseek-v4-pro", "deepseek-v4-flash",
|
||||
"mimo-v2.5", "mimo-v2.5-pro",
|
||||
"minimax-m3", "minimax-m2.7", "minimax-m2.5",
|
||||
"qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("marks documented Qwen and MiniMax models as Anthropic messages format", () => {
|
||||
for (const model of MESSAGES_MODELS) {
|
||||
expect(getModelTargetFormat("opencode-go", model)).toBe("claude");
|
||||
describe("OpenCode Go per-model supportedFormats", () => {
|
||||
it("declares [openai, claude] for MiniMax + Qwen models", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => {
|
||||
for (const model of CHAT_MODELS) {
|
||||
expect(getModelTargetFormat("opencode-go", model)).toBeNull();
|
||||
it("declares [openai, claude, openai-responses] for DeepSeek models", () => {
|
||||
for (const m of RESPONSES_CAPABLE) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude", "openai-responses"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => {
|
||||
for (const m of CHAT_ONLY) {
|
||||
expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go endpoint routing", () => {
|
||||
it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => {
|
||||
const executor = new OpenCodeGoExecutor();
|
||||
describe("OpenCode Go multi-endpoint transports", () => {
|
||||
it("declares openai / claude / openai-responses transports", () => {
|
||||
const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format);
|
||||
expect(formats).toEqual(["openai", "claude", "openai-responses"]);
|
||||
});
|
||||
|
||||
for (const model of MESSAGES_MODELS) {
|
||||
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
|
||||
expect(headers["x-api-key"]).toBe("sk-test");
|
||||
expect(headers["anthropic-version"]).toBeDefined();
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
|
||||
expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
|
||||
expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions");
|
||||
});
|
||||
|
||||
it("uses x-api-key + anthropicVersion on the claude transport", () => {
|
||||
const t = resolveTransport("opencode-go", "claude");
|
||||
expect(t.auth.header).toBe("x-api-key");
|
||||
expect(t.auth.anthropicVersion).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode Go per-model transport guard (chatCore logic)", () => {
|
||||
it("routes MiniMax/Qwen + claude-format client to /messages", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(pickTransport("opencode-go", "claude", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
|
||||
}
|
||||
});
|
||||
|
||||
it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => {
|
||||
const executor = new OpenCodeGoExecutor();
|
||||
it("does NOT route chat-only models to /messages on a claude-format request", () => {
|
||||
for (const m of CHAT_ONLY) {
|
||||
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
for (const model of CHAT_MODELS) {
|
||||
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions");
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false);
|
||||
expect(headers.Authorization).toBe("Bearer sk-test");
|
||||
expect(headers["x-api-key"]).toBeUndefined();
|
||||
expect(headers["anthropic-version"]).toBeUndefined();
|
||||
it("routes DeepSeek + responses-format client to /responses", () => {
|
||||
for (const m of RESPONSES_CAPABLE) {
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT route MiniMax (no responses support) to /responses", () => {
|
||||
for (const m of CLAUDE_CAPABLE) {
|
||||
expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,17 @@ describe("standalone build assets", () => {
|
|||
.toBe("static asset");
|
||||
});
|
||||
|
||||
// Without the wrapper beside server.js nothing can prove a request is local.
|
||||
it("copies the request-sanitizing server wrapper into the standalone output", () => {
|
||||
const projectRoot = createBuildFixture(".next");
|
||||
writeFileSync(join(projectRoot, "custom-server.js"), "wrapper");
|
||||
|
||||
copyStandaloneAssets({ projectRoot, distDir: ".next" });
|
||||
|
||||
expect(readFileSync(join(projectRoot, ".next", "standalone", "custom-server.js"), "utf8"))
|
||||
.toBe("wrapper");
|
||||
});
|
||||
|
||||
it("does not modify workspace-traced CLI builds", () => {
|
||||
const projectRoot = createBuildFixture(".next-cli-build");
|
||||
const previousMode = process.env.NEXT_TRACING_ROOT_MODE;
|
||||
|
|
|
|||
Loading…
Reference in a new issue