Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7855472499 | ||
|
|
a6ed974ece | ||
|
|
4c11b97197 |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.10"
|
version = "0.2.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.10"
|
version = "0.2.13"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+29
-5
@@ -694,15 +694,25 @@ async fn delete_model(State(state): State<SharedState>, Path(id): Path<String>)
|
|||||||
|
|
||||||
async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -> ApiResult {
|
||||||
let live = state.live();
|
let live = state.live();
|
||||||
let mcfg = live
|
// Provider string: from the static pool, or "openai" for a managed local
|
||||||
|
// model (which is synthesized into the registry, not [[models]]).
|
||||||
|
let provider_str = live
|
||||||
.cfg
|
.cfg
|
||||||
.models
|
.models
|
||||||
.iter()
|
.iter()
|
||||||
.find(|m| m.id == id)
|
.find(|m| m.id == id)
|
||||||
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?
|
.map(|m| m.provider.clone())
|
||||||
.clone();
|
.or_else(|| {
|
||||||
|
live.cfg
|
||||||
|
.cmf
|
||||||
|
.effective_servers()
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.map(|_| "openai".to_string())
|
||||||
|
})
|
||||||
|
.ok_or_else(|| ApiError::not_found(format!("model '{id}' not found")))?;
|
||||||
|
|
||||||
if mcfg.provider == "anthropic" {
|
if provider_str == "anthropic" {
|
||||||
return ok(json!({
|
return ok(json!({
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "anthropic provider not yet implemented (planned v0.2)",
|
"error": "anthropic provider not yet implemented (planned v0.2)",
|
||||||
@@ -739,7 +749,21 @@ async fn probe_model(State(state): State<SharedState>, Path(id): Path<String>) -
|
|||||||
let latency_ms = started.elapsed().as_millis() as u64;
|
let latency_ms = started.elapsed().as_millis() as u64;
|
||||||
match res {
|
match res {
|
||||||
Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })),
|
Ok(_) => ok(json!({ "ok": true, "latency_ms": latency_ms })),
|
||||||
Err(e) => ok(json!({ "ok": false, "latency_ms": latency_ms, "error": e.to_string() })),
|
Err(e) => {
|
||||||
|
let msg = e.to_string();
|
||||||
|
// Make auth failures actionable — a raw "User not found" (OpenRouter's
|
||||||
|
// 401) usually means the API key is wrong or the wrong type.
|
||||||
|
let hint = if msg.contains("401")
|
||||||
|
|| msg.contains("Unauthorized")
|
||||||
|
|| msg.contains("User not found")
|
||||||
|
|| msg.contains("No auth")
|
||||||
|
{
|
||||||
|
" — check the API key (for OpenRouter use an inference key, not a provisioning/management key)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
ok(json!({ "ok": false, "latency_ms": latency_ms, "error": format!("{msg}{hint}") }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -129,6 +129,7 @@ export default {
|
|||||||
"models.form.secretMissing": "not set",
|
"models.form.secretMissing": "not set",
|
||||||
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
"models.form.secretPlaceholder": "set or replace key (write-only)",
|
||||||
"models.deleteConfirm": "Delete model \"{id}\"?",
|
"models.deleteConfirm": "Delete model \"{id}\"?",
|
||||||
|
"models.form.idOrModel": "Enter an id or a model name",
|
||||||
"models.form.apiKey": "API Key",
|
"models.form.apiKey": "API Key",
|
||||||
"models.form.apiKeyPlaceholder": "paste your API key",
|
"models.form.apiKeyPlaceholder": "paste your API key",
|
||||||
"models.form.apiKeyNotNeeded": "not required (local server)",
|
"models.form.apiKeyNotNeeded": "not required (local server)",
|
||||||
@@ -315,6 +316,10 @@ export default {
|
|||||||
"settings.route.maxChars": "Max chars",
|
"settings.route.maxChars": "Max chars",
|
||||||
"settings.route.cacheTtl": "Cache TTL",
|
"settings.route.cacheTtl": "Cache TTL",
|
||||||
"settings.route.profile": "Default profile",
|
"settings.route.profile": "Default profile",
|
||||||
|
"settings.route.strategyHelp": "What text is sent to the router for classification: last_user = only the last user message; last_user_plus_system = last user message + system prompt; concat_all = the whole conversation.",
|
||||||
|
"settings.route.maxCharsHelp": "Max characters of that text sent to the router (longer is truncated). More = more accurate classification, but slower/pricier router calls.",
|
||||||
|
"settings.route.cacheTtlHelp": "How long a routing decision is cached for an identical request before the router is asked again (e.g. 60s, 5m).",
|
||||||
|
"settings.route.profileHelp": "Default routing profile when a request doesn't specify one: cost-saver (cheapest), balanced, or quality-first (best model). Biases which model the router picks.",
|
||||||
"settings.breaker": "Circuit breaker",
|
"settings.breaker": "Circuit breaker",
|
||||||
"settings.breaker.threshold": "Error threshold",
|
"settings.breaker.threshold": "Error threshold",
|
||||||
"settings.breaker.cooldown": "Cooldown",
|
"settings.breaker.cooldown": "Cooldown",
|
||||||
|
|||||||
Vendored
+5
@@ -128,6 +128,7 @@ export default {
|
|||||||
"models.form.secretMissing": "не задан",
|
"models.form.secretMissing": "не задан",
|
||||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
||||||
|
"models.form.idOrModel": "Укажите id или имя модели",
|
||||||
"models.form.apiKey": "API-ключ",
|
"models.form.apiKey": "API-ключ",
|
||||||
"models.form.apiKeyPlaceholder": "вставьте API-ключ",
|
"models.form.apiKeyPlaceholder": "вставьте API-ключ",
|
||||||
"models.form.apiKeyNotNeeded": "не нужен (локальный сервер)",
|
"models.form.apiKeyNotNeeded": "не нужен (локальный сервер)",
|
||||||
@@ -314,6 +315,10 @@ export default {
|
|||||||
"settings.route.maxChars": "Макс. символов",
|
"settings.route.maxChars": "Макс. символов",
|
||||||
"settings.route.cacheTtl": "TTL кэша",
|
"settings.route.cacheTtl": "TTL кэша",
|
||||||
"settings.route.profile": "Профиль по умолчанию",
|
"settings.route.profile": "Профиль по умолчанию",
|
||||||
|
"settings.route.strategyHelp": "Какой текст отправляется роутеру для классификации: last_user — только последнее сообщение пользователя; last_user_plus_system — последнее сообщение + системный промпт; concat_all — вся переписка.",
|
||||||
|
"settings.route.maxCharsHelp": "Максимум символов этого текста, отправляемого роутеру (длиннее — обрезается). Больше = точнее классификация, но медленнее/дороже вызовы роутера.",
|
||||||
|
"settings.route.cacheTtlHelp": "Сколько кэшируется решение роутинга для одинакового запроса, прежде чем роутер спрашивается снова (напр. 60s, 5m).",
|
||||||
|
"settings.route.profileHelp": "Профиль роутинга по умолчанию, если запрос его не задаёт: cost-saver (дешевле), balanced (баланс) или quality-first (качество). Влияет на выбор модели роутером.",
|
||||||
"settings.breaker": "Автоматический выключатель",
|
"settings.breaker": "Автоматический выключатель",
|
||||||
"settings.breaker.threshold": "Порог ошибок",
|
"settings.breaker.threshold": "Порог ошибок",
|
||||||
"settings.breaker.cooldown": "Время восстановления",
|
"settings.breaker.cooldown": "Время восстановления",
|
||||||
|
|||||||
Vendored
+3
-1
@@ -25,7 +25,9 @@ export function clear(node) {
|
|||||||
|
|
||||||
export function mount(node, ...children) {
|
export function mount(node, ...children) {
|
||||||
clear(node);
|
clear(node);
|
||||||
for (const c of children.flat()) if (c != null && c !== false) node.appendChild(c);
|
for (const c of children.flat())
|
||||||
|
if (c != null && c !== false)
|
||||||
|
node.appendChild(typeof c === "object" ? c : document.createTextNode(String(c)));
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+23
-2
@@ -26,6 +26,13 @@ const PROVIDER_DEFAULTS = {
|
|||||||
http: { base: "", keyEnv: "", needsKey: false, tier: "cheap", caps: ["tools"] },
|
http: { base: "", keyEnv: "", needsKey: false, tier: "cheap", caps: ["tools"] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Derive a clean model id from a model name (last path segment, slugified).
|
||||||
|
// e.g. "qwen/qwen3.7-max" → "qwen3-7-max".
|
||||||
|
function slugId(name) {
|
||||||
|
const base = (name || "").split("/").pop() || "";
|
||||||
|
return base.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
function modelForm(meta, existing) {
|
function modelForm(meta, existing) {
|
||||||
const m = existing || { provider: "openai", kind: "chat", cost_tier: "mid", caps: ["tools"], price_in: 0, price_out: 0 };
|
const m = existing || { provider: "openai", kind: "chat", cost_tier: "mid", caps: ["tools"], price_in: 0, price_out: 0 };
|
||||||
const idIn = h("input", { value: m.id || "", placeholder: "my-model", disabled: existing ? true : null });
|
const idIn = h("input", { value: m.id || "", placeholder: "my-model", disabled: existing ? true : null });
|
||||||
@@ -70,6 +77,17 @@ function modelForm(meta, existing) {
|
|||||||
providerSel.addEventListener("change", applyDefaults);
|
providerSel.addEventListener("change", applyDefaults);
|
||||||
if (!existing) 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
idIn.addEventListener("input", () => { idIn._auto = false; });
|
||||||
|
}
|
||||||
|
|
||||||
async function testKey() {
|
async function testKey() {
|
||||||
if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; }
|
if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; }
|
||||||
testBtn.disabled = true;
|
testBtn.disabled = true;
|
||||||
@@ -142,8 +160,11 @@ async function openModal(meta, existing, reload) {
|
|||||||
const { node, getValue } = modelForm(meta, existing);
|
const { node, getValue } = modelForm(meta, existing);
|
||||||
modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => {
|
modal(existing ? t("models.form.editTitle") : t("models.form.addTitle"), node, async () => {
|
||||||
const { model, secret, keyEnv } = getValue();
|
const { model, secret, keyEnv } = getValue();
|
||||||
|
// Forgot the id? Derive it from the model name so saving never errors on a
|
||||||
|
// blank id.
|
||||||
|
if (!model.id) model.id = slugId(model.model);
|
||||||
if (!model.id) {
|
if (!model.id) {
|
||||||
toast(t("models.form.id") + "?", "bad");
|
toast(t("models.form.idOrModel"), "bad");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!model.api_key_env) delete model.api_key_env; // keep serde field optional
|
if (!model.api_key_env) delete model.api_key_env; // keep serde field optional
|
||||||
@@ -264,7 +285,7 @@ function row(m, meta, reload) {
|
|||||||
try {
|
try {
|
||||||
await api.deleteModel(m.id);
|
await api.deleteModel(m.id);
|
||||||
toast(t("toast.deleted"), "good");
|
toast(t("toast.deleted"), "good");
|
||||||
reload();
|
await reload();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(String(e.message || e), "bad");
|
toast(String(e.message || e), "bad");
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+5
-3
@@ -442,9 +442,11 @@ export async function renderSettings() {
|
|||||||
"div",
|
"div",
|
||||||
{ class: "card" },
|
{ class: "card" },
|
||||||
h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))),
|
h("div", { class: "card-head" }, h("h3", {}, t("settings.route"))),
|
||||||
field(t("settings.route.strategy"), strategySel),
|
field(t("settings.route.strategy"), strategySel, null, t("settings.route.strategyHelp")),
|
||||||
h("div", { class: "row" }, field(t("settings.route.maxChars"), maxChars), field(t("settings.route.cacheTtl"), cacheTtl)),
|
h("div", { class: "row" },
|
||||||
field(t("settings.route.profile"), profileSel)
|
field(t("settings.route.maxChars"), maxChars, null, t("settings.route.maxCharsHelp")),
|
||||||
|
field(t("settings.route.cacheTtl"), cacheTtl, null, t("settings.route.cacheTtlHelp"))),
|
||||||
|
field(t("settings.route.profile"), profileSel, null, t("settings.route.profileHelp"))
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
|
|||||||
Reference in New Issue
Block a user