feat: add embeddings endpoint and config for OpenAI-compatible text embeddings via gateway

Add POST /openai/v1/embeddings route in openai_gateway router, new config fields for embedding upstream URL/model/key/enabled toggle with defaults pointing to OpenRouter Qwen3 8B, INTERNAL_EMBED_MODEL constant in config.py, documentation in docs_api.py and README.md describing the molodetz~embed model mapping, and embed-call tracking in gateway metrics alongside existing chat/vision counters.
This commit is contained in:
2026-06-14 01:06:18 +00:00
parent 1076696dec
commit c7770ee21a
17 changed files with 662 additions and 62 deletions
+1
View File
@@ -53,6 +53,7 @@ INTERNAL_BASE_URL = environ.get(
).rstrip("/")
INTERNAL_GATEWAY_URL = f"{INTERNAL_BASE_URL}/openai/v1/chat/completions"
INTERNAL_MODEL = "molodetz"
INTERNAL_EMBED_MODEL = "molodetz~embed"
SERVICE_LOCK_FILE = LOCKS_DIR / "devplace-services.lock"
INIT_LOCK_FILE = LOCKS_DIR / "devplace-init.lock"
+42
View File
@@ -3580,6 +3580,10 @@ describes the image with a configured vision model and rewrites it to text, so a
upstream still works. The vision model, URL, and key are configured alongside the other gateway
settings.
The gateway additionally serves **text embeddings** at `/openai/v1/embeddings`. Clients request the
generic model `molodetz~embed`, which the gateway maps to the configured embedding model (OpenRouter's
Qwen3 8B embedding model by default). Usage and cost are tracked per call exactly like chat and vision.
Administrators enable and configure this gateway under [Background Services](/docs/services.html)
(the `openai` service).
@@ -3624,6 +3628,44 @@ for signing DevPlace's own requests.
],
notes=["Returns `503` when the gateway service is not running."],
),
endpoint(
id="gateway-embeddings",
method="POST",
path="/openai/v1/embeddings",
title="Embeddings",
summary="OpenAI-compatible text embeddings. Request model molodetz~embed.",
auth="user",
encoding="json",
params=[
field(
"model",
"json",
"string",
False,
"molodetz~embed",
"Embedding model id; the gateway maps molodetz~embed to the configured model.",
),
field(
"input",
"json",
"string",
True,
'"some text to embed"',
"String or array of strings to embed.",
),
field(
"dimensions",
"json",
"string",
False,
"4096",
"Optional output vector size (Matryoshka, 32-4096).",
),
],
notes=[
"Returns `503` when the gateway service is not running or embeddings are disabled."
],
),
endpoint(
id="gateway-passthrough",
method="POST",
+5
View File
@@ -20,6 +20,11 @@ async def chat_completions(request: Request):
return await _service().handle(request, "chat/completions")
@router.post("/v1/embeddings")
async def embeddings(request: Request):
return await _service().handle(request, "embeddings")
@router.api_route("/v1/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
async def passthrough(request: Request, path: str):
return await _service().handle(request, path)
@@ -10,6 +10,9 @@ VISION_URL_DEFAULT = "https://openrouter.ai/api/v1/chat/completions"
VISION_MODEL_DEFAULT = "google/gemma-3-12b-it"
VISION_CACHE_SIZE_DEFAULT = 256
EMBED_URL_DEFAULT = "https://openrouter.ai/api/v1/embeddings"
EMBED_MODEL_DEFAULT = "qwen/qwen3-embedding-8b"
VISION_INSTRUCTION = (
"Describe this image in detail. Note objects, people, scene, any visible "
"text, layout, colors, and anything else that could be relevant for "
@@ -21,6 +24,7 @@ PRICE_CACHE_MISS_PER_M_DEFAULT = 0.14
PRICE_OUTPUT_PER_M_DEFAULT = 0.28
VISION_PRICE_INPUT_PER_M_DEFAULT = 0.0
VISION_PRICE_OUTPUT_PER_M_DEFAULT = 0.0
EMBED_PRICE_INPUT_PER_M_DEFAULT = 0.01
USAGE_RETENTION_HOURS_DEFAULT = 720
@@ -101,6 +101,7 @@ class GatewayRuntime:
self.in_flight = 0
self.peak_in_flight = 0
self.vision_calls = 0
self.embed_calls = 0
self.last_status = 0
self.last_latency_ms = 0
@@ -345,6 +346,137 @@ class GatewayRuntime:
)
return JSONResponse(content=data)
async def handle_embeddings(
self, body: dict, cfg: dict, owner: tuple, user_agent: str, log=None
):
log = log or (lambda message: None)
if not cfg["gateway_embed_enabled"]:
return JSONResponse(
status_code=503,
content={
"error": {
"message": "Embeddings are disabled",
"type": "embeddings_disabled",
}
},
)
client, sem = self._ensure(cfg)
pricing = pricing_from_cfg(cfg)
context_map = parse_context_map(cfg.get("gateway_model_context_map"))
params = extract_params(body)
handle_start = time.monotonic()
requested = body.get("model")
if cfg["gateway_force_model"] or not requested or requested == "molodetz~embed":
model = cfg["gateway_embed_model"]
else:
model = requested
payload = dict(body)
payload["model"] = model
headers = {"Content-Type": "application/json"}
if cfg["gateway_embed_key"]:
headers["Authorization"] = f"Bearer {cfg['gateway_embed_key']}"
else:
log(
"No upstream embeddings API key configured (gateway_embed_key / gateway_vision_key / OPENROUTER_API_KEY); upstream will likely reject the request"
)
resp, exc, timing = await self._send(
client,
sem,
"POST",
cfg["gateway_embed_url"],
headers,
cfg,
log,
json_body=payload,
)
base = {
"owner_kind": owner[0],
"owner_id": owner[1],
"backend": "embed",
"endpoint": "embeddings",
"model": model,
"user_agent": user_agent,
**params,
**timing,
}
def finalize(status_code, success, category, usage=None):
base["total_latency_ms"] = round(
(time.monotonic() - handle_start) * 1000, 3
)
base["gateway_overhead_ms"] = round(
max(
base["total_latency_ms"]
- timing["upstream_latency_ms"]
- timing["queue_wait_ms"],
0.0,
),
3,
)
base["status_code"] = status_code
base["success"] = success
base["error_category"] = category
base["usage"] = usage
self._ledger.record(base, pricing, context_map)
if timing["circuit_open"]:
finalize(503, False, "circuit_open")
return JSONResponse(
status_code=503,
content={
"error": {
"message": "Upstream temporarily unavailable",
"type": "circuit_open",
}
},
)
if exc is not None:
finalize(502, False, classify_error(0, exc))
return JSONResponse(
status_code=502,
content={
"error": {
"message": f"Upstream connection failed: {exc}",
"type": "upstream_error",
}
},
)
if resp.status_code != 200:
finalize(
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
)
log(f"embed upstream POST -> {resp.status_code}: {resp.text[:300]}")
return JSONResponse(
status_code=resp.status_code,
content={"error": {"message": resp.text, "type": "upstream_error"}},
)
try:
data = resp.json()
except ValueError:
self.errors += 1
finalize(502, False, "gateway")
log("embed upstream returned 200 but body was not valid JSON")
return JSONResponse(
status_code=502,
content={
"error": {
"message": "invalid upstream response",
"type": "upstream_error",
}
},
)
self.embed_calls += 1
finalize(200, True, None, data.get("usage"))
log(f"embed POST -> 200 ({timing['upstream_latency_ms']:.0f}ms)")
return JSONResponse(content=data)
async def handle_passthrough(
self,
method: str,
@@ -458,6 +590,7 @@ class GatewayRuntime:
"in_flight": self.in_flight,
"peak_in_flight": self.peak_in_flight,
"vision_calls": self.vision_calls,
"embed_calls": self.embed_calls,
"last_status": self.last_status,
"last_latency_ms": self.last_latency_ms,
"pool": self._instances,
@@ -130,6 +130,38 @@ class GatewayService(BaseService):
help="Image-description LRU cache entries (0 disables caching).",
group="Vision",
),
ConfigField(
"gateway_embed_enabled",
"Embeddings",
type="bool",
default=True,
help="Expose the embeddings model at /openai/v1/embeddings.",
group="Embeddings",
),
ConfigField(
"gateway_embed_url",
"Embeddings URL",
type="url",
default=config.EMBED_URL_DEFAULT,
help="OpenAI-compatible embeddings endpoint requests are forwarded to.",
group="Embeddings",
),
ConfigField(
"gateway_embed_model",
"Embeddings model",
type="str",
default=config.EMBED_MODEL_DEFAULT,
help="Embedding model sent upstream. Clients request it as molodetz~embed.",
group="Embeddings",
),
ConfigField(
"gateway_embed_key",
"Embeddings API key",
type="str",
default="",
help="The key currently in use; falls back to the vision/OPENROUTER key on boot (embeddings default to OpenRouter). Editable.",
group="Embeddings",
),
ConfigField(
"gateway_require_auth",
"Require authentication",
@@ -216,6 +248,15 @@ class GatewayService(BaseService):
minimum=0,
group="Pricing",
),
ConfigField(
"gateway_embed_price_input_per_m",
"Embeddings price input / 1M ($)",
type="float",
default=config.EMBED_PRICE_INPUT_PER_M_DEFAULT,
minimum=0,
help="Fallback only; used when the embeddings upstream returns no native cost.",
group="Pricing",
),
ConfigField(
"gateway_max_retries",
"Max retries",
@@ -290,6 +331,11 @@ class GatewayService(BaseService):
cfg["gateway_vision_key"] = cfg["gateway_vision_key"] or os.environ.get(
"OPENROUTER_API_KEY", ""
)
cfg["gateway_embed_key"] = (
cfg["gateway_embed_key"]
or cfg["gateway_vision_key"]
or os.environ.get("OPENROUTER_API_KEY", "")
)
return cfg
def authorize(self, request: Request) -> bool:
@@ -352,6 +398,18 @@ class GatewayService(BaseService):
self.log("Rejected chat request: JSON body was not an object")
raise HTTPException(status_code=400, detail="Invalid JSON body")
return await runtime.handle_chat(body, cfg, owner, user_agent, self.log)
if subpath == "embeddings" and request.method == "POST":
try:
body = await request.json()
except Exception:
self.log("Rejected embeddings request: invalid JSON body")
raise HTTPException(status_code=400, detail="Invalid JSON body")
if not isinstance(body, dict):
self.log("Rejected embeddings request: JSON body was not an object")
raise HTTPException(status_code=400, detail="Invalid JSON body")
return await runtime.handle_embeddings(
body, cfg, owner, user_agent, self.log
)
body = await request.body()
content_type = request.headers.get("content-type", "")
return await runtime.handle_passthrough(
@@ -395,6 +453,7 @@ class GatewayService(BaseService):
"in_flight": 0,
"peak_in_flight": 0,
"vision_calls": 0,
"embed_calls": 0,
"last_status": 0,
"last_latency_ms": 0,
"pool": 0,
@@ -406,11 +465,13 @@ class GatewayService(BaseService):
{"label": "Requests (lifetime)", "value": m["requests"]},
{"label": "In flight", "value": m["in_flight"]},
{"label": "Vision calls", "value": m["vision_calls"]},
{"label": "Embed calls", "value": m["embed_calls"]},
{"label": "Last status", "value": m["last_status"] or "-"},
{"label": "Last latency", "value": f"{m['last_latency_ms']} ms"},
{"label": "Pool size", "value": m["pool"]},
{"label": "Circuit", "value": "open" if m["circuit_open"] else "closed"},
{"label": "Model", "value": cfg["gateway_model"]},
{"label": "Embed model", "value": cfg["gateway_embed_model"]},
{"label": "Requests 24h", "value": s["requests"]},
{"label": "Success 24h", "value": f"{s['success_pct']}%"},
{"label": "Error rate 24h", "value": f"{s['error_pct']}%"},
@@ -35,6 +35,7 @@ class Pricing:
chat_output_per_m: float
vision_input_per_m: float
vision_output_per_m: float
embed_input_per_m: float
def pricing_from_cfg(cfg: dict) -> Pricing:
@@ -64,6 +65,12 @@ def pricing_from_cfg(cfg: dict) -> Pricing:
config.VISION_PRICE_OUTPUT_PER_M_DEFAULT,
)
),
embed_input_per_m=float(
cfg.get(
"gateway_embed_price_input_per_m",
config.EMBED_PRICE_INPUT_PER_M_DEFAULT,
)
),
)
@@ -123,6 +130,9 @@ def compute_cost(
input_cost = norm["prompt"] / PER_MILLION * pricing.vision_input_per_m
output_cost = norm["completion"] / PER_MILLION * pricing.vision_output_per_m
return input_cost + output_cost, input_cost, output_cost, False
if backend == "embed":
input_cost = norm["prompt"] / PER_MILLION * pricing.embed_input_per_m
return input_cost, input_cost, 0.0, False
input_cost = (
norm["cache_hit"] / PER_MILLION * pricing.chat_cache_hit_per_m
+ norm["cache_miss"] / PER_MILLION * pricing.chat_cache_miss_per_m
+1
View File
@@ -543,6 +543,7 @@ img {
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon:hover { color: var(--text-primary); }
.topnav-icon.active { color: var(--accent); }
.nav-badge {
position: absolute; top: 0; right: 0; z-index: 1; min-width: 16px; height: 16px;
padding: 0 0.25rem; border-radius: 8px; background: var(--accent); color: var(--white);
+61 -7
View File
@@ -1,10 +1,57 @@
/* retoor <retoor@molodetz.nl> */
.tools-page,
.seo-tool {
.tools-page {
max-width: 920px;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
padding: var(--space-xl) var(--space-lg) var(--space-2xl);
}
.seo-layout {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
gap: var(--space-xl);
align-items: start;
}
.seo-main {
min-width: 0;
}
.seo-side-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.5;
}
.seo-side-list li {
padding-left: var(--space-md);
position: relative;
}
.seo-side-list li::before {
content: "";
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent);
}
.seo-form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-xl);
margin-bottom: var(--space-xl);
}
.tools-header h1,
@@ -44,10 +91,6 @@
.tool-card-name { font-weight: 600; color: var(--text-primary); }
.tool-card-desc { font-size: 0.8125rem; color: var(--text-secondary); }
.seo-form {
margin-bottom: 1.5rem;
}
.seo-form-row {
display: flex;
flex-wrap: wrap;
@@ -275,6 +318,17 @@
.seo-report-target { color: var(--text-muted); word-break: break-all; }
@media (max-width: 1024px) {
.seo-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 600px) {
.seo-form-card { padding: var(--space-lg); }
.seo-form-row { flex-direction: column; align-items: stretch; }
.seo-input[type="url"] { flex: 1 1 auto; }
.seo-pages { justify-content: space-between; }
.seo-run-btn { width: 100%; }
.seo-score-card { flex-direction: column; align-items: flex-start; }
}
+10 -6
View File
@@ -69,15 +69,14 @@
<a href="/tools/seo" class="dropdown-item"><span class="icon">🔍</span> SEO Diagnostics</a>
</div>
</div>
{% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages{% set msg_unread = get_unread_messages(user["uid"]) %}<span class="nav-badge nav-badge-inline" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></a>
{% if is_admin(user) %}
<a href="/admin" class="topnav-link {% if 'admin' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Admin</a>
{% endif %}
{% endif %}
</div>
<div class="topnav-right">
{% if user %}
<a href="/messages" class="topnav-icon {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages" title="Messages" aria-label="Messages">
<span class="nav-bell">✉️</span>
{% set msg_unread = get_unread_messages(user["uid"]) %}
<span class="nav-badge" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span>
</a>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">🔕</span>
</button>
@@ -86,6 +85,11 @@
{% set unread_count = get_unread_count(user["uid"]) %}
<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span>
</a>
{% if is_admin(user) %}
<a href="/admin" class="topnav-icon {% if 'admin' in request.url.path %}active{% endif %}" title="Admin" aria-label="Admin">
<span class="nav-bell">⚙️</span>
</a>
{% endif %}
<div class="topnav-user-dropdown">
<button type="button" class="topnav-user" aria-label="User menu">
<img src="{{ avatar_url('multiavatar', user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}" loading="lazy">
+82 -41
View File
@@ -1,57 +1,98 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/sidebar.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/seo-tool.css') }}">
{% endblock %}
{% block content %}
<div class="seo-tool" data-seo-tool>
<header class="seo-header">
<h1><span class="icon">🔍</span> SEO Diagnostics</h1>
<p>Audit any URL or sitemap against a broad battery of technical, on-page, structured-data,
Core Web Vitals, accessibility and AI-readiness checks. Progress streams live.</p>
</header>
<form class="seo-form" data-seo-form autocomplete="off">
<div class="seo-form-row">
<input type="url" name="url" class="seo-input" placeholder="https://example.com" required>
<select name="mode" class="seo-select" data-seo-mode>
<option value="url">Single URL</option>
<option value="sitemap">Sitemap</option>
</select>
<label class="seo-pages" data-seo-pages hidden>
Pages
<input type="number" name="max_pages" class="seo-input seo-input-num" value="10" min="1" max="50">
</label>
<button type="submit" class="btn btn-primary seo-run-btn" data-seo-run>Run audit</button>
<div class="seo-layout" data-seo-tool>
<aside class="sidebar-card">
<div class="sidebar-heading">SEO Diagnostics</div>
<div class="sidebar-nav">
<a href="/tools" class="sidebar-link">
<span class="icon">&#x1F9F0;</span>
All tools
</a>
<a href="/tools/seo" class="sidebar-link active">
<span class="icon">&#x1F50D;</span>
SEO audit
</a>
</div>
<p class="seo-form-error" data-seo-error hidden></p>
</form>
<section class="seo-live" data-seo-live hidden>
<div class="seo-live-head">
<span class="seo-live-status" data-seo-status>Starting…</span>
<span class="seo-live-count" data-seo-count></span>
<div class="sidebar-section">
<div class="sidebar-heading">What it checks</div>
<ul class="seo-side-list">
<li>Crawlability and indexing</li>
<li>Meta tags and titles</li>
<li>Structured data (JSON-LD)</li>
<li>Core Web Vitals</li>
<li>Accessibility and mobile</li>
<li>AI-readiness</li>
</ul>
</div>
<div class="seo-progress"><div class="seo-progress-bar" data-seo-bar></div></div>
<ul class="seo-log" data-seo-log></ul>
</section>
<section class="seo-report" data-seo-report hidden>
<div class="seo-score-card">
<div class="seo-gauge" data-seo-gauge>
<span class="seo-gauge-score" data-seo-score>0</span>
<span class="seo-gauge-grade" data-seo-grade></span>
<div class="sidebar-section">
<div class="sidebar-heading">Tips</div>
<ul class="seo-side-list">
<li>Use Single URL for one page, Sitemap to crawl many.</li>
<li>Sitemap mode is capped at 50 pages per run.</li>
<li>Only one audit runs at a time per visitor.</li>
<li>Progress streams live below as pages load.</li>
</ul>
</div>
</aside>
<div class="seo-main">
<header class="seo-header">
<h1><span class="icon">🔍</span> SEO Diagnostics</h1>
<p>Audit any URL or sitemap against a broad battery of technical, on-page, structured-data,
Core Web Vitals, accessibility and AI-readiness checks. Progress streams live.</p>
</header>
<section class="seo-form-card">
<form class="seo-form" data-seo-form autocomplete="off">
<div class="seo-form-row">
<input type="url" name="url" class="seo-input" placeholder="https://example.com" required>
<select name="mode" class="seo-select" data-seo-mode>
<option value="url">Single URL</option>
<option value="sitemap">Sitemap</option>
</select>
<label class="seo-pages" data-seo-pages hidden>
Pages
<input type="number" name="max_pages" class="seo-input seo-input-num" value="10" min="1" max="50">
</label>
<button type="submit" class="btn btn-primary seo-run-btn" data-seo-run>Run audit</button>
</div>
<p class="seo-form-error" data-seo-error hidden></p>
</form>
</section>
<section class="seo-live" data-seo-live hidden>
<div class="seo-live-head">
<span class="seo-live-status" data-seo-status>Starting…</span>
<span class="seo-live-count" data-seo-count></span>
</div>
<div class="seo-score-meta">
<h2 data-seo-target></h2>
<div class="seo-counts" data-seo-counts></div>
<a class="seo-report-link" data-seo-report-link href="#" target="_blank" rel="noopener">Open full report</a>
<div class="seo-progress"><div class="seo-progress-bar" data-seo-bar></div></div>
<ul class="seo-log" data-seo-log></ul>
</section>
<section class="seo-report" data-seo-report hidden>
<div class="seo-score-card">
<div class="seo-gauge" data-seo-gauge>
<span class="seo-gauge-score" data-seo-score>0</span>
<span class="seo-gauge-grade" data-seo-grade></span>
</div>
<div class="seo-score-meta">
<h2 data-seo-target></h2>
<div class="seo-counts" data-seo-counts></div>
<a class="seo-report-link" data-seo-report-link href="#" target="_blank" rel="noopener">Open full report</a>
</div>
</div>
</div>
<div class="seo-categories" data-seo-categories></div>
<div class="seo-checks" data-seo-checks></div>
</section>
<div class="seo-categories" data-seo-categories></div>
<div class="seo-checks" data-seo-checks></div>
</section>
</div>
</div>
{% endblock %}