Files
devplacepy/devplacepy/templates/admin_gateway.html
T
2026-07-22 23:55:46 +02:00

128 lines
13 KiB
HTML

{% extends "admin_base.html" %}
{% block extra_head %}
{{ super() }}
<link rel="stylesheet" href="{{ static_url('/static/css/gateway.css') }}">
{% endblock %}
{% block admin_content %}
<div id="gateway-admin">
<div class="admin-toolbar">
<h2>Gateway routing</h2>
<span class="admin-count" id="gw-count" role="status" aria-live="polite"></span>
</div>
<p class="gw-intro">Map any requested model name onto a provider and target model, each with its own pricing economy and an optional vision model for image to text merging. Image routes use a flat per-image price. Unmapped requests fall through to the default upstream unchanged.</p>
<section class="gw-section">
<div class="gw-section-head">
<h3>Providers</h3>
</div>
<p class="gw-section-hint">Named upstreams reused across model routes. A model route with a blank provider uses the default below.</p>
<div class="gw-default" id="gw-default" role="status" aria-live="polite"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Providers</caption>
<thead>
<tr><th scope="col">Name</th><th scope="col">Base URL</th><th scope="col">Active</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-providers"></tbody>
</table>
</div>
<form class="gw-form" id="gw-provider-form" autocomplete="off" role="group" aria-label="Add or update provider">
<p class="gw-form-title">Add or update provider</p>
<div class="gw-field"><label for="gw-provider-name">Name</label><input type="text" id="gw-provider-name" name="name" placeholder="openrouter" required aria-required="true"></div>
<div class="gw-field"><label for="gw-provider-base-url">Base URL (chat completions)</label><input type="url" id="gw-provider-base-url" name="base_url" placeholder="https://openrouter.ai/api/v1/chat/completions"></div>
<div class="gw-field"><label for="gw-provider-api-key">API key</label><input type="password" id="gw-provider-api-key" name="api_key" placeholder="sk-..." autocomplete="new-password"></div>
<div class="gw-field"><label for="gw-provider-active">Active</label><select id="gw-provider-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save provider</button></div>
</form>
</section>
<section class="gw-section">
<div class="gw-section-head">
<h3>Model routes</h3>
</div>
<p class="gw-section-hint">Each source model maps to a provider and target model with its own economy. Chat uses cache-hit, cache-miss and output prices (USD per 1M tokens); embeddings use the input price; image routes use the input price as a flat USD per image; a vision model adds input and output pricing for the image description merge.</p>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Model routes</caption>
<thead>
<tr><th scope="col">Source</th><th scope="col">Provider</th><th scope="col">Target</th><th scope="col">Kind</th><th scope="col">Vision</th><th scope="col">Economy</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-models"></tbody>
</table>
</div>
<form class="gw-form" id="gw-model-form" autocomplete="off" role="group" aria-label="Add or update model route">
<p class="gw-form-title">Add or update model route</p>
<div class="gw-field"><label for="gw-model-source">Source model (requested)</label><input type="text" id="gw-model-source" name="source_model" placeholder="gpt-4o" required aria-required="true"></div>
<div class="gw-field"><label for="gw-model-provider">Provider</label><select id="gw-model-provider" name="provider" data-provider-select></select></div>
<div class="gw-field"><label for="gw-model-target">Target model (upstream)</label><input type="text" id="gw-model-target" name="target_model" placeholder="openai/gpt-4o" required aria-required="true"></div>
<div class="gw-field"><label for="gw-model-kind">Kind</label><select id="gw-model-kind" name="kind"><option value="chat">chat</option><option value="embed">embed</option><option value="image">image</option></select></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-vision-provider">Vision provider</label><select id="gw-model-vision-provider" name="vision_provider" data-provider-select></select></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-vision-model">Vision model</label><input type="text" id="gw-model-vision-model" name="vision_model" placeholder="(optional) google/gemma-3-12b-it"></div>
<div class="gw-field"><label for="gw-model-context">Context window</label><input type="number" id="gw-model-context" name="context_window" min="0" value="0"></div>
<div class="gw-field"><label for="gw-model-active">Active</label><select id="gw-model-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-hit">Price cache-hit / 1M ($)</label><input type="number" id="gw-model-price-cache-hit" name="price_cache_hit_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-miss">Price cache-miss / 1M ($)</label><input type="number" id="gw-model-price-cache-miss" name="price_cache_miss_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-output">Price output / 1M ($)</label><input type="number" id="gw-model-price-output" name="price_output_per_m" min="0" step="0.0001" value="0"></div>
<div class="gw-field" data-kind-field="embed chat"><label for="gw-model-price-input" id="gw-model-price-input-label">Price input / 1M ($) (embed/vision)</label><input type="number" id="gw-model-price-input" name="price_input_per_m" min="0" step="0.0001" value="0"></div>
<p class="gw-form-title gw-form-subtitle" data-kind-field="chat embed">Tiered / off-peak pricing (optional)</p>
<p class="gw-section-hint" data-kind-field="chat embed">Some providers charge a different rate once a request crosses a context-length threshold, or discount a fixed time-of-day window. Leave blank/zero to keep the flat rates above at all times.</p>
<div class="gw-field" data-kind-field="chat embed"><label for="gw-model-tier-threshold">Tier-2 threshold (input tokens)</label><input type="number" id="gw-model-tier-threshold" name="context_tier_threshold_tokens" min="0" value="0" title="0 disables tiered pricing"></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-hit-tier2">Tier-2 price cache-hit / 1M ($)</label><input type="number" id="gw-model-price-cache-hit-tier2" name="price_cache_hit_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-cache-miss-tier2">Tier-2 price cache-miss / 1M ($)</label><input type="number" id="gw-model-price-cache-miss-tier2" name="price_cache_miss_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
<div class="gw-field" data-kind-field="chat"><label for="gw-model-price-output-tier2">Tier-2 price output / 1M ($)</label><input type="number" id="gw-model-price-output-tier2" name="price_output_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
<div class="gw-field" data-kind-field="chat embed"><label for="gw-model-price-input-tier2">Tier-2 price input / 1M ($) (embed/vision)</label><input type="number" id="gw-model-price-input-tier2" name="price_input_per_m_tier2" min="0" step="0.0001" placeholder="same as tier 1"></div>
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-start">Off-peak start (UTC)</label><input type="time" id="gw-model-off-peak-start" name="off_peak_start" title="Leave blank to disable off-peak discount"></div>
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-end">Off-peak end (UTC)</label><input type="time" id="gw-model-off-peak-end" name="off_peak_end" title="Leave blank to disable off-peak discount"></div>
<div class="gw-field" data-kind-field="chat embed image"><label for="gw-model-off-peak-discount">Off-peak discount (%)</label><input type="number" id="gw-model-off-peak-discount" name="off_peak_discount_pct" min="0" max="100" step="0.1" value="0"></div>
<div class="gw-form-actions"><button type="submit" class="admin-btn admin-btn-primary">Save model route</button></div>
</form>
</section>
<section class="gw-section">
<div class="gw-section-head">
<h3>Quota rules</h3>
</div>
<p class="gw-section-hint">Rolling 24h USD caps on <code class="gw-code">/openai/v1/*</code>. A rule scopes by any combination of role, specific user, and app label (the <code class="gw-code">X-App-Reference</code> header); the most specific active match wins, and a rule that omits a dimension pools spend across everyone matching it (e.g. an app-only rule caps that app's combined usage across all callers). With no matching rule, the global defaults below apply per caller. A limit of 0 means unlimited.</p>
<div class="gw-default" id="gw-quota-defaults" role="status" aria-live="polite"></div>
<div class="admin-table-wrap">
<table class="admin-table">
<caption class="sr-only">Quota rules</caption>
<thead>
<tr><th scope="col">Scope</th><th scope="col">Limit / 24h</th><th scope="col">Spent 24h</th><th scope="col">Active</th><th scope="col">Label</th><th scope="col" class="gw-actions">Actions</th></tr>
</thead>
<tbody id="gw-quota-rules"></tbody>
</table>
</div>
<form class="gw-form" id="gw-quota-form" autocomplete="off" role="group" aria-label="Add or update quota rule">
<p class="gw-form-title">Add or update quota rule</p>
<input type="hidden" id="gw-quota-uid" name="uid" value="">
<div class="gw-field"><label for="gw-quota-owner-kind">Role</label>
<select id="gw-quota-owner-kind" name="owner_kind">
<option value="">any</option>
<option value="user">member</option>
<option value="admin">admin</option>
<option value="anonymous">guest (unauthenticated)</option>
<option value="internal">internal (DevPlace's own services)</option>
<option value="key">static access key</option>
</select>
</div>
<div class="gw-field"><label for="gw-quota-owner-id">Specific user uid</label><input type="text" id="gw-quota-owner-id" name="owner_id" placeholder="(optional) exact uid, blank = any"></div>
<div class="gw-field"><label for="gw-quota-app-reference">App reference</label><input type="text" id="gw-quota-app-reference" name="app_reference" placeholder="(optional) devplace-bots-v-1-0-0, blank = any"></div>
<div class="gw-field"><label for="gw-quota-limit">Limit / 24h ($)</label><input type="number" id="gw-quota-limit" name="limit_usd" min="0" step="0.01" value="0" title="0 = unlimited"></div>
<div class="gw-field"><label for="gw-quota-active">Active</label><select id="gw-quota-active" name="is_active"><option value="1">Yes</option><option value="0">No</option></select></div>
<div class="gw-field"><label for="gw-quota-label">Label</label><input type="text" id="gw-quota-label" name="label" placeholder="(optional) admin note" maxlength="200"></div>
<div class="gw-form-actions">
<button type="submit" class="admin-btn admin-btn-primary">Save quota rule</button>
<button type="button" class="admin-btn admin-btn-sm" id="gw-quota-cancel-edit" hidden>Cancel edit</button>
</div>
</form>
</section>
</div>
{% endblock %}
{% block extra_js %}
<script type="module">
import { GatewayAdmin } from "{{ static_url('/static/js/GatewayAdmin.js') }}";
new GatewayAdmin(document.getElementById("gateway-admin")).start();
</script>
{% endblock %}