Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5d234a8
fix(translator): resolve Kiro REQUEST_BODY_INVALID for system field +…
ctdan Aug 6, 2026
d6923f6
fix(security): remove undisclosed Google Analytics telemetry + SSRF g…
ctdan Aug 6, 2026
8464f6e
fix(translator): NVIDIA/vLLM reasoning_content passthrough + adaptive…
ctdan Aug 6, 2026
0f14c48
docs: complete user and developer guides + provider configuration ref…
ctdan Aug 6, 2026
f7cfb68
feat(providers): add TRAE AI, Reasonix IDE, OVH AI Endpoints + DeepSe…
ctdan Aug 6, 2026
21866bf
feat(providers): add JD JoyCode provider (issue #3046)
ctdan Aug 6, 2026
b85c07b
feat(security): add rate limiting to chat/completions and embeddings …
ctdan Aug 6, 2026
71e3cd0
fix(models/test): increase max_tokens for reasoning models in dashboa…
ctdan Aug 7, 2026
cf58899
fix(providers): NVIDIA API keys not saving - add multi-service config…
ctdan Aug 7, 2026
c37be73
fix(combo): add stream_options for DeepSeek fusion combo judge calls
ctdan Aug 7, 2026
4edc4e3
fix: streaming usage tracking, login redirect, standalone assets, bus…
ctdan Aug 7, 2026
c05ea21
feat: latency monitoring for provider selection + Codex custom provid…
ctdan Aug 7, 2026
1c763f2
fix: ENABLE_REQUEST_LOGS runtime check, Trae OAuth, headroom auth, he…
ctdan Aug 7, 2026
43d3237
fix(cli-tools): Apply button disabled for custom providers without te…
ctdan Aug 7, 2026
1e92f92
feat(usage): locale-aware cost display (#2976)
ctdan Aug 7, 2026
eaa03ed
feat(providers): add openmodel.ai provider (#2984)
ctdan Aug 7, 2026
ef67152
fix(api): add try/catch error handling to chat completions endpoint (…
ctdan Aug 7, 2026
2158463
fix(security): add /api/pxpipe/start and /api/pxpipe/stop to LOCAL_ON…
ctdan Aug 7, 2026
2396f19
fix(security): add SSRF guard on search baseUrl for client-supplied o…
ctdan Aug 7, 2026
0d02dad
fix(auth): enforce requireApiKey on GET /v1/models (#3085)
ctdan Aug 7, 2026
4966880
fix(auth): verify local JWT-less browser on local-only routes when lo…
ctdan Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 26 additions & 20 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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:
6 changes: 6 additions & 0 deletions open-sse/executors/kiro.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
18 changes: 5 additions & 13 deletions open-sse/handlers/chatCore/streamingHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 68 additions & 1 deletion open-sse/handlers/search/callers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(/\/+$/, "");
}

/**
Expand Down
2 changes: 1 addition & 1 deletion open-sse/providers/registry/deepseek.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down
10 changes: 10 additions & 0 deletions open-sse/providers/registry/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -239,4 +244,9 @@ export default [
p117,
p118,
p119,
p120,
p121,
p122,
p123,
p124,
];
31 changes: 31 additions & 0 deletions open-sse/providers/registry/joycode.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
26 changes: 26 additions & 0 deletions open-sse/providers/registry/openmodel.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
37 changes: 37 additions & 0 deletions open-sse/providers/registry/ovh.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
31 changes: 31 additions & 0 deletions open-sse/providers/registry/reasonix.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
Loading