diff --git a/Cargo.toml b/Cargo.toml index 95fce2f..c8dbeb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index 2a4166c..203e0fc 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -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 = v["data"] + let mut models: Vec = 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::().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::().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 { diff --git a/web/index.html b/web/index.html index 0b17335..b8c5196 100644 --- a/web/index.html +++ b/web/index.html @@ -19,6 +19,6 @@
Loading…
- + diff --git a/web/js/app.js b/web/js/app.js index 2466005..e5d7734 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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"; diff --git a/web/js/views/models.js b/web/js/views/models.js index eaa93c3..dae3c2c 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -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 );