Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
127 changes: 127 additions & 0 deletions contrib/nginx/endpoint_routing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* OmniRoute Endpoint-Aware Model Routing (njs module)
*
* Rewrites the model prefix based on the client endpoint:
* /v1/responses → main/<model> (→ KFC, responses provider)
* /v1/chat/completions → old/<model> (→ KFC-Old, chat provider)
*
* Handles all client input forms:
* "gpt-5.5-pro" → adds correct prefix for the endpoint
* "main/gpt-5.5-pro" → rewrites prefix if endpoint doesn't match
* "old/gpt-5.5-pro" → rewrites prefix if endpoint doesn't match
*
* Models that belong to OTHER providers (e.g. gemini-cli) are
* NOT rewritten — they pass through to OmniRoute alias resolution.
*
* Does NOT modify the request body — sets the X-Route-Model header
* via js_var + internalRedirect. OmniRoute reads this header in
* resolveRoutingModel() and uses it for provider routing.
* SSE response streaming is fully preserved.
*/

var KNOWN_PREFIXES = ["main/", "old/"];

// Model name prefixes that belong to OTHER providers (not KFC/KFC-Old).
// These models must NOT be rewritten — let OmniRoute handle them
// via aliases or direct provider/model resolution.
var SKIP_MODEL_PREFIXES = [
"gemini-",
"gemini/",
"gemini-cli/",
];

function stripPrefix(model) {
for (var i = 0; i < KNOWN_PREFIXES.length; i++) {
if (model.indexOf(KNOWN_PREFIXES[i]) === 0) {
return model.substring(KNOWN_PREFIXES[i].length);
}
}
return model;
}

function prefixForEndpoint(uri) {
if (uri.indexOf("/responses") !== -1) {
return "main/";
}
return "old/";
}

function shouldSkipModel(model) {
// If model already has a provider prefix (contains "/") and it's
// NOT one of our known KFC prefixes, don't touch it
var slashIdx = model.indexOf("/");
if (slashIdx !== -1) {
var prefix = model.substring(0, slashIdx + 1);
var isKfcPrefix = false;
for (var i = 0; i < KNOWN_PREFIXES.length; i++) {
if (prefix === KNOWN_PREFIXES[i]) {
isKfcPrefix = true;
break;
}
}
if (!isKfcPrefix) {
return true;
}
}

// Check bare model names that belong to other providers
for (var j = 0; j < SKIP_MODEL_PREFIXES.length; j++) {
if (model.indexOf(SKIP_MODEL_PREFIXES[j]) === 0) {
return true;
}
}

return false;
}

/**
* js_content handler: reads request body, computes the correct
* prefixed model name, stores it in $route_model (js_var),
* then does an internal redirect to the proxy location.
* The original request body is preserved across the redirect.
*
* For OPTIONS requests (CORS preflight), responds directly.
* For non-KFC models (gemini, etc.), proxies without rewrite.
*/
function handleRequest(r) {
// Handle CORS preflight
if (r.method === "OPTIONS") {
r.headersOut["Access-Control-Allow-Origin"] = "*";
r.headersOut["Access-Control-Allow-Methods"] =
"GET, POST, OPTIONS, PUT, DELETE, PATCH";
r.headersOut["Access-Control-Allow-Headers"] =
"Authorization, Content-Type, Accept, Origin, User-Agent, X-Requested-With";
r.headersOut["Access-Control-Max-Age"] = "1728000";
r.headersOut["Content-Type"] = "text/plain; charset=utf-8";
r.return(204);
return;
}

// For non-POST methods, proxy without model rewrite
if (r.method !== "POST") {
r.internalRedirect("/_omni_proxy" + r.uri);
return;
}

// Parse body and rewrite model prefix
var body;
try {
body = JSON.parse(r.requestText || "{}");
} catch (e) {
r.internalRedirect("/_omni_proxy" + r.uri);
return;
}

var model = body.model;
if (model && typeof model === "string") {
var bare = stripPrefix(model);
if (!shouldSkipModel(bare)) {
var prefix = prefixForEndpoint(r.uri);
r.variables.route_model = prefix + bare;
}
}

r.internalRedirect("/_omni_proxy" + r.uri);
}

export default { handleRequest };

Check warning on line 127 in contrib/nginx/endpoint_routing.js

View workflow job for this annotation

GitHub Actions / Lint

Assign object to a variable before exporting as module default
155 changes: 155 additions & 0 deletions contrib/nginx/omnirouter.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# OmniRoute — endpoint-aware model routing via njs
#
# Requires: libnginx-mod-http-js (njs)
# Install: sudo apt-get install -y libnginx-mod-http-js
#
# njs script: /etc/nginx/njs/endpoint_routing.js
# This config: /etc/nginx/sites-enabled/omnirouter.conf

js_import njs/endpoint_routing.js;
js_var $route_model;

server {
server_name route.smartaipack.ru;

# ── Endpoint-aware API routing ─────────────────────────────────
# /v1/responses → model prefix "main/" → KFC (responses)
# /v1/chat/completions → model prefix "old/" → KFC-Old (chat)
#
# The njs handler reads body.model, rewrites the prefix,
# stores it in $route_model, and does an internal redirect
# to /_omni_proxy/... which proxies to OmniRoute with
# X-Route-Model header set.

location = /v1/responses {
# Buffer request body so njs can read it
client_body_in_single_buffer on;
client_body_buffer_size 10m;
client_max_body_size 100m;

js_content endpoint_routing.handleRequest;
}

location = /v1/chat/completions {
client_body_in_single_buffer on;
client_body_buffer_size 10m;
client_max_body_size 100m;

js_content endpoint_routing.handleRequest;
}

# ── Internal proxy to OmniRoute API (port 20131) ──────────────
# Receives internal redirects from njs handlers above.
# Forwards the original request body + adds X-Route-Model header.
location /_omni_proxy/ {
internal;

# Strip the /_omni_proxy prefix, forward original path
rewrite ^/_omni_proxy(/.*)$ $1 break;

# Set the rewritten model header (empty string = no override)
proxy_set_header X-Route-Model $route_model;

proxy_pass http://127.0.0.1:20131;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# SSE streaming: disable buffering
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400;

# CORS
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin, User-Agent, X-Requested-With' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range' always;
}

# ── Other /v1/* API routes (no model rewrite needed) ──────────
location /v1 {
proxy_pass http://127.0.0.1:20131;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

proxy_buffering off;
proxy_cache off;
proxy_read_timeout 86400;

# CORS
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin, User-Agent, X-Requested-With' always;
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range' always;

if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin, User-Agent, X-Requested-With';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
Comment on lines +72 to +98

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Fallback-location /v1 не фильтрует клиентский заголовок X-Route-Model

Блок location /v1 (omnirouter.conf:72-98) проксирует запросы к OmniRoute без установки proxy_set_header X-Route-Model. В отличие от /_omni_proxy/ (строка 51), где заголовок явно устанавливается из $route_model, здесь nginx пропускает оригинальный клиентский заголовок X-Route-Model как есть. Это означает, что клиент может напрямую отправить X-Route-Model: any/model на любой /v1/* эндпоинт (кроме /v1/responses и /v1/chat/completions) и переопределить маршрутизацию. Функция resolveRoutingModel() в src/sse/handlers/resolveRoutingModel.ts:15-16 примет этот заголовок. Это может быть намеренным (документировано в PR cheaper-inference#4863), но стоит убедиться, что это не создаёт нежелательный обход логики маршрутизации для эндпоинтов, которые не проходят через njs.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and not a regression. X-Route-Model is a documented OmniRoute feature (PR cheaper-inference#4863) — it's designed to let proxies/callers override routing. The /v1 fallback location handles endpoints like /v1/models, /v1/embeddings etc. that don't need model prefix rewriting. Clients who send X-Route-Model directly are using the feature as designed, and the header still passes through enforceApiKeyPolicy so it can't bypass per-key allowlists.

Note: this OmniRoute PR is a duplicate — the canonical PR was merged in ai-service (cheaper-inference#140).


# ── Dashboard (port 20130) ────────────────────────────────────
location / {
proxy_pass http://127.0.0.1:20130;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/route.smartaipack.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/route.smartaipack.ru/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

# ── Dozzle Logs ───────────────────────────────────────────────────
server {
server_name logs.smartaipack.ru;

location / {
proxy_pass http://127.0.0.1:9999;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}

listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/route.smartaipack.ru/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/route.smartaipack.ru/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}

# ── HTTP → HTTPS redirects ───────────────────────────────────────
server {
if ($host = route.smartaipack.ru) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name route.smartaipack.ru;
return 404; # managed by Certbot
}

server {
if ($host = logs.smartaipack.ru) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name logs.smartaipack.ru;
return 404; # managed by Certbot
}
Loading