feat(admin): sync openrouter capabilities with emojis
Docker / publish (push) Canceled after 0s
Release / crates-io (push) Canceled after 0s
Release / release (macos-14, aarch64-apple-darwin) (push) Canceled after 0s
Release / release (ubuntu-latest, x86_64-unknown-linux-gnu) (push) Canceled after 0s
Release / release (windows-latest, x86_64-pc-windows-msvc) (push) Canceled after 0s

This commit is contained in:
infosave2007
2026-07-22 18:59:31 +03:00
parent f2e6047bff
commit a7c8ed72c7
5 changed files with 125 additions and 25 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
version = "0.2.32"
version = "0.2.33"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
+58 -4
View File
@@ -467,16 +467,70 @@ async fn provider_models(
}
// OpenAI + Anthropic both return { "data": [ { "id": ... } ] }.
let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
let mut models: Vec<String> = v["data"]
let mut models: Vec<Value> = v["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.filter_map(|m| {
let id = m["id"].as_str()?.to_string();
let mut obj = json!({ "id": id });
let mut caps = vec![];
if let Some(pricing) = m.get("pricing").filter(|v| !v.is_null()) {
if let Some(prompt) = pricing.get("prompt").and_then(|s| s.as_str()).and_then(|s| s.parse::<f64>().ok()) {
obj["price_in"] = json!(prompt * 1_000_000.0);
}
if let Some(comp) = pricing.get("completion").and_then(|s| s.as_str()).and_then(|s| s.parse::<f64>().ok()) {
obj["price_out"] = json!(comp * 1_000_000.0);
}
if pricing.get("input_cache_read").filter(|v| !v.is_null()).is_some() || pricing.get("input_cache_write").filter(|v| !v.is_null()).is_some() {
caps.push("caching");
}
}
if let Some(params) = m.get("supported_parameters").and_then(|p| p.as_array()) {
let has_tools = params.iter().any(|p| p.as_str() == Some("tools") || p.as_str() == Some("tool_choice"));
if has_tools { caps.push("tools"); }
let has_json = params.iter().any(|p| p.as_str() == Some("response_format"));
if has_json { caps.push("json"); }
let has_reasoning = params.iter().any(|p| p.as_str() == Some("reasoning") || p.as_str() == Some("include_reasoning"));
if has_reasoning || m.get("reasoning").filter(|v| !v.is_null()).is_some() { caps.push("reasoning"); }
}
if let Some(arch) = m.get("architecture").filter(|v| !v.is_null()) {
if let Some(inputs) = arch.get("input_modalities").and_then(|i| i.as_array()) {
if inputs.iter().any(|i| i.as_str() == Some("image")) { caps.push("vision"); }
if inputs.iter().any(|i| i.as_str() == Some("audio")) { caps.push("audio"); }
}
}
if !caps.is_empty() {
obj["caps"] = json!(caps);
}
Some(obj)
})
.collect()
})
.unwrap_or_default();
models.sort();
ok(json!({ "ok": true, "count": models.len(), "models": models }))
models.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
let mut resp = json!({ "ok": true, "count": models.len(), "models": models });
if b.provider == "openrouter" {
if let Some(k) = key {
if let Ok(cred_resp) = client.get("https://openrouter.ai/api/v1/credits")
.header("authorization", format!("Bearer {k}"))
.send().await
{
if let Ok(cred_body) = cred_resp.text().await {
let cred_v: Value = serde_json::from_str(&cred_body).unwrap_or(json!({}));
if let Some(data) = cred_v.get("data") {
let total = data.get("total_credits").and_then(|v| v.as_f64()).unwrap_or(0.0);
let usage = data.get("total_usage").and_then(|v| v.as_f64()).unwrap_or(0.0);
resp["balance"] = json!(total - usage);
}
}
}
}
}
ok(resp)
}
async fn meta() -> ApiResult {
+1 -1
View File
@@ -19,6 +19,6 @@
<noscript>This console requires JavaScript.</noscript>
<div id="app"><div class="boot">Loading…</div></div>
<div id="toasts" class="toasts"></div>
<script type="module" src="/admin/js/app.js"></script>
<script type="module" src="/admin/js/app.js?v=3"></script>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@ import { t, getLang, setLang, onLangChange, LANGS } from "./i18n.js";
import { api, getToken, setToken, setUnauthorizedHandler } from "./api.js";
import { renderLogin } from "./views/login.js";
import { renderDashboard } from "./views/dashboard.js";
import { renderModels } from "./views/models.js";
import { renderModels } from "./views/models.js?v=3";
import { renderImport } from "./views/import.js";
import { renderRouting } from "./views/routing.js";
import { renderProtocols } from "./views/protocols.js";
+64 -18
View File
@@ -51,7 +51,16 @@ function modelForm(meta, existing) {
const tierSel = h("select", {}, ...(meta.cost_tiers || ["cheap"]).map((c) => opt(c, c, c === m.cost_tier)));
const priceInIn = h("input", { type: "number", step: "0.01", value: m.price_in ?? 0 });
const priceOutIn = h("input", { type: "number", step: "0.01", value: m.price_out ?? 0 });
const capInputs = (meta.caps || ["tools", "vision"]).map((c) => {
const CAP_EMOJIS = {
tools: "🔧 tools",
vision: "👁️ vision",
audio: "🎙️ audio",
reasoning: "🧠 reasoning",
json: "📄 json",
caching: "⚡ caching"
};
const capInputs = (meta.caps || Object.keys(CAP_EMOJIS)).map((c) => {
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
return { c, cb };
});
@@ -70,25 +79,55 @@ function modelForm(meta, existing) {
capInputs.forEach(({ c, cb }) => { cb.checked = d.caps.includes(c); });
keyIn.placeholder = d.needsKey ? t("models.form.apiKeyPlaceholder") : t("models.form.apiKeyNotNeeded");
mount(keyStatus, "");
// Automatically pre-fetch models (especially useful for OpenRouter which doesn't need a key)
testKey(true);
}
providerSel.addEventListener("change", applyDefaults);
if (!existing) applyDefaults();
// Auto-fill the id from the model name (until the user edits id by hand).
if (!existing) {
modelIn.addEventListener("input", () => {
if (!idIn.value || idIn._auto) {
idIn.value = slugId(modelIn.value);
idIn._auto = true;
let cachedModels = [];
const autofillData = () => {
console.log("autofillData triggered. modelIn.value:", modelIn.value);
console.log("cachedModels length:", cachedModels.length);
if (!existing && (!idIn.value || idIn._auto)) {
idIn.value = slugId(modelIn.value);
idIn._auto = true;
console.log("auto-filled idIn:", idIn.value);
}
const found = cachedModels.find((m) => (m.id || m) === modelIn.value);
console.log("found model:", found);
if (found) {
if (found.price_in !== undefined) {
priceInIn.value = parseFloat(Number(found.price_in).toFixed(4));
priceOutIn.value = parseFloat(Number(found.price_out).toFixed(4));
console.log("Updated prices:", priceInIn.value, priceOutIn.value);
}
});
if (found.caps && Array.isArray(found.caps)) {
capInputs.forEach(({ c, cb }) => {
cb.checked = found.caps.includes(c);
});
console.log("Updated caps:", found.caps);
}
}
};
modelIn.addEventListener("input", autofillData);
modelIn.addEventListener("change", autofillData);
if (!existing) {
idIn.addEventListener("input", () => { idIn._auto = false; });
} else {
console.log("Editing existing model, fetching testKey(true)");
testKey(true);
}
async function testKey() {
if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; }
async function testKey(silent = false) {
if (!baseUrlIn.value.trim()) {
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?"));
return;
}
testBtn.disabled = true;
mount(keyStatus, t("models.form.testing"));
if (!silent) mount(keyStatus, t("models.form.testing"));
try {
const r = await api.providerModels({
provider: providerSel.value,
@@ -97,18 +136,25 @@ function modelForm(meta, existing) {
api_key_env: envFor(),
});
if (r.ok) {
dl.replaceChildren(...(r.models || []).map((mm) => h("option", { value: mm })));
mount(keyStatus, h("span", { style: "color:var(--good)" }, "✓ " + t("models.form.foundModels", { n: r.count })));
modelIn.focus();
cachedModels = r.models || [];
dl.replaceChildren(...cachedModels.map((mm) => h("option", { value: mm.id || mm })));
let msg = "✓ " + t("models.form.foundModels", { n: r.count });
if (r.balance !== undefined) {
msg += ` (Balance: $${r.balance.toFixed(4)})`;
}
mount(keyStatus, h("span", { style: "color:var(--good)" }, msg));
if (!silent) modelIn.focus();
} else {
mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + (r.error || t("common.error"))));
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + (r.error || t("common.error"))));
}
} catch (e) {
mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + String(e.message || e)));
if (!silent) mount(keyStatus, h("span", { style: "color:var(--bad)" }, "✗ " + String(e.message || e)));
}
testBtn.disabled = false;
}
testBtn.addEventListener("click", testKey);
testBtn.addEventListener("click", () => testKey(false));
keyIn.addEventListener("change", () => testKey(true));
const keyHint =
existing && existing.key_source === "store" ? t("models.form.keyStored")
@@ -155,7 +201,7 @@ function modelForm(meta, existing) {
dl,
h("div", { class: "row" }, field(t("models.form.kind"), kindSel), field(t("models.form.costTier"), tierSel)),
h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)),
field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c)))),
field(t("models.form.caps"), h("div", { class: "flex wrap", style: "gap:10px" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check", style: "margin:0" }, cb, CAP_EMOJIS[c] || c)))),
advParams
);