// 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 = [];
this.quotaRulesBody = root.querySelector("#gw-quota-rules");
this.quotaForm = root.querySelector("#gw-quota-form");
this.quotaCancelEdit = root.querySelector("#gw-quota-cancel-edit");
this.quotaRules = [];
}
async start() {
this.bind();
this.syncKindFields();
await this.reload();
}
syncKindFields() {
const kind = this.modelForm.kind.value || "chat";
const priceInputLabel = this.root.querySelector("#gw-model-price-input-label");
if (priceInputLabel) {
priceInputLabel.textContent =
kind === "image"
? "Price per image ($)"
: "Price input / 1M ($) (embed/vision)";
}
this.root.querySelectorAll("[data-kind-field]").forEach((field) => {
const kinds = (field.dataset.kindField || "").split(/\s+/).filter(Boolean);
field.style.display = kinds.includes(kind) ? "" : "none";
});
}
bind() {
this.providerForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveProvider();
});
this.modelForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveModel();
});
this.modelForm.kind.addEventListener("change", () => this.syncKindFields());
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
this.quotaForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveQuotaRule();
});
this.quotaRulesBody.addEventListener("click", (event) => this.onQuotaRuleClick(event));
this.quotaCancelEdit.addEventListener("click", () => this.resetQuotaForm());
}
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 quotaCount = await this.loadQuotaRules();
const count = this.root.querySelector("#gw-count");
if (count) {
count.textContent = `${providerCount} providers, ${modelCount} routes, ${quotaCount} quota rules`;
}
}
escape(value) {
const span = document.createElement("span");
span.textContent = value == null ? "" : String(value);
return span.innerHTML;
}
attr(value) {
return this.escape(value).split('"').join("&quot;");
}
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>,
image <code class="gw-code">${this.escape(def.image_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="7" 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";
const badges = [];
if (m.context_tier_threshold_tokens) {
badges.push(`<span class="gw-badge" title="Tier-2 rates above ${m.context_tier_threshold_tokens} input tokens">tiered</span>`);
}
if (m.off_peak_start_minute !== null && m.off_peak_start_minute !== undefined) {
const start = this.minutesToTime(m.off_peak_start_minute);
const end = this.minutesToTime(m.off_peak_end_minute);
badges.push(`<span class="gw-badge" title="${m.off_peak_discount_pct}% off ${start}-${end} UTC">off-peak</span>`);
}
if (m.kind === "image" && m.price_input_per_m) {
badges.push(`<span class="gw-badge" title="Flat price per generated image">$${m.price_input_per_m}/img</span>`);
}
const economy = badges.length ? badges.join(" ") : `<span class="gw-muted">-</span>`;
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>${economy}</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;
}
timeToMinutes(value) {
if (!value) return null;
const [hours, minutes] = value.split(":").map((part) => parseInt(part, 10));
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
return hours * 60 + minutes;
}
minutesToTime(minutes) {
if (minutes === null || minutes === undefined || minutes === "") return "";
const total = parseInt(minutes, 10);
if (Number.isNaN(total)) return "";
const hours = Math.floor(total / 60) % 24;
const mins = total % 60;
return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}
optionalFloat(value) {
if (value === "" || value === null || value === undefined) return null;
const parsed = parseFloat(value);
return Number.isNaN(parsed) ? null : parsed;
}
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,
context_tier_threshold_tokens: parseInt(values.context_tier_threshold_tokens, 10) || 0,
price_cache_hit_per_m_tier2: this.optionalFloat(values.price_cache_hit_per_m_tier2),
price_cache_miss_per_m_tier2: this.optionalFloat(values.price_cache_miss_per_m_tier2),
price_output_per_m_tier2: this.optionalFloat(values.price_output_per_m_tier2),
price_input_per_m_tier2: this.optionalFloat(values.price_input_per_m_tier2),
off_peak_start_minute: this.timeToMinutes(values.off_peak_start),
off_peak_end_minute: this.timeToMinutes(values.off_peak_end),
off_peak_discount_pct: parseFloat(values.off_peak_discount_pct) || 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";
this.syncKindFields();
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.context_tier_threshold_tokens.value = model.context_tier_threshold_tokens || 0;
form.price_cache_hit_per_m_tier2.value = model.price_cache_hit_per_m_tier2 ?? "";
form.price_cache_miss_per_m_tier2.value = model.price_cache_miss_per_m_tier2 ?? "";
form.price_output_per_m_tier2.value = model.price_output_per_m_tier2 ?? "";
form.price_input_per_m_tier2.value = model.price_input_per_m_tier2 ?? "";
form.off_peak_start.value = this.minutesToTime(model.off_peak_start_minute);
form.off_peak_end.value = this.minutesToTime(model.off_peak_end_minute);
form.off_peak_discount_pct.value = model.off_peak_discount_pct || 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");
}
}
async loadQuotaRules() {
const data = await Http.getJson("/admin/gateway/quota-rules");
this.quotaRules = data.rules || [];
this.renderQuotaDefaults(data.defaults || {});
this.renderQuotaRules();
return this.quotaRules.length;
}
renderQuotaDefaults(defaults) {
const el = this.root.querySelector("#gw-quota-defaults");
if (!el) return;
const fmt = (v) => (Number(v) > 0 ? `$${Number(v).toFixed(2)}/24h` : "unlimited");
el.innerHTML = `
<strong>global defaults</strong> (from <a href="/admin/services">Services config</a>, apply per caller with no matching rule):
member <code class="gw-code">${fmt(defaults.user)}</code>,
admin <code class="gw-code">${fmt(defaults.admin)}</code>,
guest <code class="gw-code">${fmt(defaults.guest)}</code>,
internal <code class="gw-code">${fmt(defaults.internal)}</code>,
access key <code class="gw-code">${fmt(defaults.key)}</code>`;
}
scopeLabel(rule) {
const parts = [];
parts.push(rule.owner_kind ? `role=${rule.owner_kind}` : "role=any");
parts.push(rule.owner_id ? `user=${rule.owner_id}` : "user=any");
parts.push(rule.app_reference ? `app=${rule.app_reference}` : "app=any");
return parts.join(", ");
}
renderQuotaRules() {
if (!this.quotaRules.length) {
this.quotaRulesBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No quota rules. Every caller is capped by the global defaults above.</td></tr>`;
return;
}
this.quotaRulesBody.innerHTML = this.quotaRules
.map((r) => {
const limit = Number(r.limit_usd) > 0 ? `$${Number(r.limit_usd).toFixed(2)}` : "unlimited";
const spent = `$${Number(r.spent_24h_usd || 0).toFixed(4)}`;
return `<tr>
<td><code class="gw-code">${this.escape(this.scopeLabel(r))}</code></td>
<td>${limit}</td>
<td>${spent}</td>
<td>${r.is_active ? "yes" : "no"}</td>
<td>${this.escape(r.label || "")}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
</td>
</tr>`;
})
.join("");
}
async saveQuotaRule() {
const values = this.formValues(this.quotaForm);
const payload = {
owner_kind: values.owner_kind || null,
owner_id: values.owner_id || null,
app_reference: values.app_reference || null,
limit_usd: parseFloat(values.limit_usd) || 0,
is_active: values.is_active === "1",
label: values.label || "",
};
if (values.uid) payload.uid = values.uid;
try {
await Http.postJson("/admin/gateway/quota-rules", payload);
this.resetQuotaForm();
this.notify("Quota rule saved", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Save failed", "error");
}
}
fillQuotaForm(rule) {
const form = this.quotaForm;
form.uid.value = rule.uid || "";
form.owner_kind.value = rule.owner_kind || "";
form.owner_id.value = rule.owner_id || "";
form.app_reference.value = rule.app_reference || "";
form.limit_usd.value = rule.limit_usd || 0;
form.is_active.value = rule.is_active ? "1" : "0";
form.label.value = rule.label || "";
this.quotaCancelEdit.hidden = false;
form.owner_kind.scrollIntoView({ block: "center" });
}
resetQuotaForm() {
this.quotaForm.reset();
this.quotaForm.uid.value = "";
this.quotaCancelEdit.hidden = true;
}
onQuotaRuleClick(event) {
const editRaw = event.target.dataset.editQuota;
const delUid = event.target.dataset.delQuota;
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
if (delUid) this.deleteQuotaRule(delUid);
}
async deleteQuotaRule(uid) {
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
try {
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
this.notify("Quota rule deleted", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Delete failed", "error");
}
}
}