forked from retoor/devplacepy
feat: add provider and model routing tables, admin UI, and audit category for gateway
Implement the multi-provider routing system for the OpenAI gateway, including two new database tables (`gateway_providers`, `gateway_models`) ensured at init, a new admin page at `/admin/gateway` with full CRUD for providers and model routes, and a `"gateway"` audit category mapped to `"ai"`. The routing layer sits transparently on top of the existing single-provider default path: unmatched model names fall through unchanged, while matched routes forward to the configured provider with their own pricing economy, vision model, and context window. Cross-worker cache invalidation uses a shared `_ROUTING_CACHE` bumped via `"gateway_routing"` cache version.
This commit is contained in:
@@ -180,6 +180,29 @@
|
||||
padding: 0.25rem 0.375rem;
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent, var(--info));
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.admin-btn-primary:hover {
|
||||
background: var(--accent-hover, var(--accent));
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.admin-btn-danger:hover {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.admin-select {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.375rem;
|
||||
|
||||
@@ -133,18 +133,6 @@
|
||||
.ci-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.admin-btn-primary {
|
||||
background: var(--accent, var(--info));
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.admin-btn-danger {
|
||||
background: var(--danger);
|
||||
color: var(--white);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.cm-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.gw-intro {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: var(--space-lg);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.gw-section {
|
||||
margin-bottom: var(--space-2xl);
|
||||
}
|
||||
|
||||
.gw-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.gw-section-head h3 {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gw-section-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: var(--space-md);
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.gw-default {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.gw-default code,
|
||||
.admin-table .gw-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.gw-default a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.gw-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gw-actions {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gw-form {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
margin-top: var(--space-md);
|
||||
max-width: 960px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.gw-form-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gw-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.gw-field.gw-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.gw-field label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.gw-field input,
|
||||
.gw-field select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: var(--bg-input, var(--bg-card));
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-input, var(--radius));
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.gw-field input:focus,
|
||||
.gw-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gw-form-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.gw-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gw-actions {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
|
||||
export class GatewayAdmin {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.providersBody = root.querySelector("#gw-providers");
|
||||
this.modelsBody = root.querySelector("#gw-models");
|
||||
this.providerForm = root.querySelector("#gw-provider-form");
|
||||
this.modelForm = root.querySelector("#gw-model-form");
|
||||
this.providerSelects = root.querySelectorAll("[data-provider-select]");
|
||||
this.providers = [];
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.bind();
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.providerForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.saveProvider();
|
||||
});
|
||||
this.modelForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
this.saveModel();
|
||||
});
|
||||
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
|
||||
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
|
||||
}
|
||||
|
||||
notify(message, type) {
|
||||
if (window.app && window.app.toast) {
|
||||
window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
}
|
||||
|
||||
async reload() {
|
||||
const providerCount = await this.loadProviders();
|
||||
const modelCount = await this.loadModels();
|
||||
const count = this.root.querySelector("#gw-count");
|
||||
if (count) {
|
||||
count.textContent = `${providerCount} providers, ${modelCount} routes`;
|
||||
}
|
||||
}
|
||||
|
||||
escape(value) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value == null ? "" : String(value);
|
||||
return span.innerHTML;
|
||||
}
|
||||
|
||||
attr(value) {
|
||||
return this.escape(value).split('"').join(""");
|
||||
}
|
||||
|
||||
async loadProviders() {
|
||||
const data = await Http.getJson("/admin/gateway/providers");
|
||||
this.providers = data.providers || [];
|
||||
this.renderDefault(data.default || {});
|
||||
this.renderProviders();
|
||||
this.fillProviderSelects();
|
||||
return this.providers.length;
|
||||
}
|
||||
|
||||
renderDefault(def) {
|
||||
const el = this.root.querySelector("#gw-default");
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<strong>default</strong> (from <a href="/admin/services">Services config</a>):
|
||||
chat <code class="gw-code">${this.escape(def.model)}</code> at <code class="gw-code">${this.escape(def.base_url)}</code>,
|
||||
embed <code class="gw-code">${this.escape(def.embed_model)}</code>, vision <code class="gw-code">${this.escape(def.vision_model)}</code>`;
|
||||
}
|
||||
|
||||
renderProviders() {
|
||||
if (!this.providers.length) {
|
||||
this.providersBody.innerHTML = `<tr><td colspan="4" class="admin-empty">No extra providers. Model routes with a blank provider use the default.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
this.providersBody.innerHTML = this.providers
|
||||
.map(
|
||||
(p) => `<tr>
|
||||
<td>${this.escape(p.name)}</td>
|
||||
<td><code class="gw-code">${this.escape(p.base_url)}</code></td>
|
||||
<td>${p.is_active ? "yes" : "no"}</td>
|
||||
<td class="gw-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-edit-provider="${this.attr(p.name)}">Edit</button>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-provider="${this.attr(p.name)}">Delete</button>
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
fillProviderSelects() {
|
||||
const options =
|
||||
`<option value="">default</option>` +
|
||||
this.providers.map((p) => `<option value="${this.attr(p.name)}">${this.escape(p.name)}</option>`).join("");
|
||||
this.providerSelects.forEach((select) => {
|
||||
const current = select.value;
|
||||
select.innerHTML = options;
|
||||
select.value = current;
|
||||
});
|
||||
}
|
||||
|
||||
async loadModels() {
|
||||
const data = await Http.getJson("/admin/gateway/models");
|
||||
const models = data.models || [];
|
||||
if (!models.length) {
|
||||
this.modelsBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No model routes. Requests fall through to the default upstream.</td></tr>`;
|
||||
return 0;
|
||||
}
|
||||
this.modelsBody.innerHTML = models
|
||||
.map((m) => {
|
||||
const vision = m.vision_model
|
||||
? `<code class="gw-code">${this.escape(m.vision_provider || m.provider || "default")}/${this.escape(m.vision_model)}</code>`
|
||||
: `<span class="gw-muted">-</span>`;
|
||||
const provider = m.provider || "default";
|
||||
return `<tr>
|
||||
<td>${this.escape(m.source_model)}</td>
|
||||
<td>${this.escape(provider)}</td>
|
||||
<td><code class="gw-code">${this.escape(m.target_model)}</code></td>
|
||||
<td>${this.escape(m.kind)}</td>
|
||||
<td>${vision}</td>
|
||||
<td class="gw-actions">
|
||||
<button class="admin-btn admin-btn-sm" data-edit-model='${this.attr(JSON.stringify(m))}'>Edit</button>
|
||||
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-model="${this.attr(m.source_model)}">Delete</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return models.length;
|
||||
}
|
||||
|
||||
formValues(form) {
|
||||
const values = {};
|
||||
new FormData(form).forEach((value, key) => {
|
||||
values[key] = value;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async saveProvider() {
|
||||
const values = this.formValues(this.providerForm);
|
||||
const payload = {
|
||||
name: values.name,
|
||||
base_url: values.base_url,
|
||||
api_key: values.api_key,
|
||||
is_active: values.is_active === "1",
|
||||
};
|
||||
try {
|
||||
await Http.postJson("/admin/gateway/providers", payload);
|
||||
this.providerForm.reset();
|
||||
this.notify("Provider saved", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Save failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async saveModel() {
|
||||
const values = this.formValues(this.modelForm);
|
||||
const payload = {
|
||||
source_model: values.source_model,
|
||||
provider: values.provider,
|
||||
target_model: values.target_model,
|
||||
kind: values.kind,
|
||||
vision_provider: values.vision_provider,
|
||||
vision_model: values.vision_model,
|
||||
context_window: parseInt(values.context_window, 10) || 0,
|
||||
price_cache_hit_per_m: parseFloat(values.price_cache_hit_per_m) || 0,
|
||||
price_cache_miss_per_m: parseFloat(values.price_cache_miss_per_m) || 0,
|
||||
price_output_per_m: parseFloat(values.price_output_per_m) || 0,
|
||||
price_input_per_m: parseFloat(values.price_input_per_m) || 0,
|
||||
is_active: values.is_active === "1",
|
||||
};
|
||||
try {
|
||||
await Http.postJson("/admin/gateway/models", payload);
|
||||
this.modelForm.reset();
|
||||
this.notify("Model route saved", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Save failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
onProviderClick(event) {
|
||||
const editName = event.target.dataset.editProvider;
|
||||
const delName = event.target.dataset.delProvider;
|
||||
if (editName) {
|
||||
const provider = this.providers.find((p) => p.name === editName);
|
||||
if (provider) this.fillProviderForm(provider);
|
||||
}
|
||||
if (delName) this.deleteProvider(delName);
|
||||
}
|
||||
|
||||
fillProviderForm(provider) {
|
||||
const form = this.providerForm;
|
||||
form.name.value = provider.name;
|
||||
form.base_url.value = provider.base_url || "";
|
||||
form.api_key.value = provider.api_key || "";
|
||||
form.is_active.value = provider.is_active ? "1" : "0";
|
||||
form.name.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
async confirmDelete(message) {
|
||||
if (window.app && window.app.dialog) {
|
||||
return window.app.dialog.confirm({ message, danger: true, confirmLabel: "Delete" });
|
||||
}
|
||||
return window.confirm(message);
|
||||
}
|
||||
|
||||
async remove(url) {
|
||||
const response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteProvider(name) {
|
||||
if (!(await this.confirmDelete(`Delete provider "${name}"?`))) return;
|
||||
try {
|
||||
await this.remove(`/admin/gateway/providers/${encodeURIComponent(name)}`);
|
||||
this.notify("Provider deleted", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Delete failed", "error");
|
||||
}
|
||||
}
|
||||
|
||||
onModelClick(event) {
|
||||
const editRaw = event.target.dataset.editModel;
|
||||
const delSource = event.target.dataset.delModel;
|
||||
if (editRaw) this.fillModelForm(JSON.parse(editRaw));
|
||||
if (delSource) this.deleteModel(delSource);
|
||||
}
|
||||
|
||||
fillModelForm(model) {
|
||||
const form = this.modelForm;
|
||||
form.source_model.value = model.source_model || "";
|
||||
form.provider.value = model.provider || "";
|
||||
form.target_model.value = model.target_model || "";
|
||||
form.kind.value = model.kind || "chat";
|
||||
form.vision_provider.value = model.vision_provider || "";
|
||||
form.vision_model.value = model.vision_model || "";
|
||||
form.context_window.value = model.context_window || 0;
|
||||
form.price_cache_hit_per_m.value = model.price_cache_hit_per_m || 0;
|
||||
form.price_cache_miss_per_m.value = model.price_cache_miss_per_m || 0;
|
||||
form.price_output_per_m.value = model.price_output_per_m || 0;
|
||||
form.price_input_per_m.value = model.price_input_per_m || 0;
|
||||
form.is_active.value = model.is_active ? "1" : "0";
|
||||
form.source_model.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
async deleteModel(source) {
|
||||
if (!(await this.confirmDelete(`Delete model route "${source}"?`))) return;
|
||||
try {
|
||||
await this.remove(`/admin/gateway/models/${encodeURIComponent(source)}`);
|
||||
this.notify("Model route deleted", "success");
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
this.notify(err.message || "Delete failed", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user