diff --git a/Cargo.lock b/Cargo.lock index 5934580..88e6dfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "cortiq-gateway" -version = "0.2.9" +version = "0.2.10" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index e22e103..75b723b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cortiq-gateway" -version = "0.2.9" +version = "0.2.10" edition = "2021" rust-version = "1.75" license = "Apache-2.0" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index a3c2dd0..f17b2a2 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -179,6 +179,7 @@ pub fn api_routes(admin_token: String) -> Router { .route("/admin/api/cmf/install", post(cmf_install)) .route("/admin/api/cmf/port", get(cmf_port_check)) .route("/admin/api/cmf/files", get(cmf_files)) + .route("/admin/api/provider/models", post(provider_models)) .route("/admin/api/requests", get(get_requests)) .route("/admin/api/test", post(run_test)) .route("/admin/api/test/stream", post(run_test_stream)) @@ -391,18 +392,105 @@ async fn router_probe(State(state): State) -> ApiResult { ok(json!({ "ok": true, "status": "ok", "latency_ms": latency_ms, "usage": usage })) } +#[derive(Deserialize)] +struct ProviderModelsBody { + provider: String, + base_url: String, + /// Key typed into the form (takes priority)… + #[serde(default)] + api_key: Option, + /// …or the name of a stored secret to reuse if no key was typed. + #[serde(default)] + api_key_env: Option, +} + +/// Concise error line from a provider's JSON error body. +fn provider_err(body: &str, status: u16) -> String { + if let Ok(v) = serde_json::from_str::(body) { + if let Some(m) = v["error"]["message"] + .as_str() + .or_else(|| v["error"].as_str()) + .or_else(|| v["message"].as_str()) + { + return format!("{status}: {m}"); + } + } + format!("HTTP {status}") +} + +/// Validate a provider API key and list its models by calling the provider's +/// `/models` endpoint — powers the "Test key & load models" button. The key is +/// used transiently (from the form) or resolved from a stored secret; it is not +/// persisted here. +async fn provider_models( + State(state): State, + Json(b): Json, +) -> ApiResult { + let base = b.base_url.trim_end_matches('/').to_string(); + if base.is_empty() { + return Err(ApiError::bad("base URL is required")); + } + let key = b.api_key.filter(|k| !k.trim().is_empty()).or_else(|| { + b.api_key_env + .as_deref() + .and_then(|e| state.secrets.resolve(e)) + }); + let key = key.as_deref(); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| ApiError::bad(e.to_string()))?; + + // Anthropic uses x-api-key + anthropic-version and /v1/models; every other + // (OpenAI-compatible) provider uses a Bearer token and /models. + let resp = if b.provider == "anthropic" { + let mut r = client + .get(format!("{base}/v1/models")) + .header("anthropic-version", "2023-06-01"); + if let Some(k) = key { + r = r.header("x-api-key", k); + } + r.send().await + } else { + let mut r = client.get(format!("{base}/models")); + if let Some(k) = key { + r = r.header("authorization", format!("Bearer {k}")); + } + r.send().await + }; + let resp = resp.map_err(|e| ApiError::bad(format!("request failed: {e}")))?; + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + if !(200..300).contains(&status) { + return ok(json!({ "ok": false, "status": status, "error": provider_err(&body, status) })); + } + // OpenAI + Anthropic both return { "data": [ { "id": ... } ] }. + let v: Value = serde_json::from_str(&body).unwrap_or(json!({})); + let mut models: Vec = v["data"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + models.sort(); + ok(json!({ "ok": true, "count": models.len(), "models": models })) +} + async fn meta() -> ApiResult { ok(json!({ "version": env!("CARGO_PKG_VERSION"), - "providers": ["openai", "anthropic", "ollama", "http"], - "provider_impl": { "openai": true, "ollama": true, "http": true, "anthropic": true }, - "cost_tiers": ["cheap", "mid", "expensive"], + "providers": ["openai", "anthropic", "openrouter", "lmstudio", "ollama", "http"], + "provider_impl": { "openai": true, "anthropic": true, "openrouter": true, "lmstudio": true, "ollama": true, "http": true }, + "cost_tiers": ["cheap", "mid", "expensive", "local"], "kinds": ["chat", "embedding"], "profiles": ["cost-saver", "balanced", "quality-first"], "text_strategies": ["last_user", "last_user_plus_system", "concat_all"], "policy_modes": ["fixed_table", "cost_aware"], "tiers": ["low", "medium", "high"], - "caps": ["tools", "vision"], + "caps": ["tools", "vision", "audio", "reasoning", "json", "caching"], "protocols_impl": { "openai_chat": true, "openai_completions": true, "openai_embeddings": true, "openai_models": true, "anthropic_messages": true, "mcp": true, "native_passthrough": true diff --git a/src/registry.rs b/src/registry.rs index ca43041..3e2fefb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -21,7 +21,10 @@ impl Registry { .as_ref() .and_then(|name| secrets.resolve(name)); let provider: Arc = match m.provider.as_str() { - "openai" | "ollama" | "http" => Arc::new(OpenAiProvider::new(m, api_key)?), + // openrouter + lmstudio are OpenAI-compatible (Bearer + /chat/completions) + "openai" | "ollama" | "http" | "openrouter" | "lmstudio" => { + Arc::new(OpenAiProvider::new(m, api_key)?) + } "anthropic" => Arc::new(AnthropicProvider::new(m, api_key)?), other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id), }; diff --git a/web/js/api.js b/web/js/api.js index 6984b4a..2d2d14a 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -100,6 +100,7 @@ export const api = { cmfInstall: () => j("POST", "/cmf/install"), cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)), cmfFiles: () => j("GET", "/cmf/files"), + providerModels: (b) => j("POST", "/provider/models", b), // CMF model factory (HuggingFace → local .cmf) hfSearch: (q, limit = 24) => diff --git a/web/js/i18n/en.js b/web/js/i18n/en.js index 91d0fbb..3bd73ee 100644 --- a/web/js/i18n/en.js +++ b/web/js/i18n/en.js @@ -129,6 +129,17 @@ export default { "models.form.secretMissing": "not set", "models.form.secretPlaceholder": "set or replace key (write-only)", "models.deleteConfirm": "Delete model \"{id}\"?", + "models.form.apiKey": "API Key", + "models.form.apiKeyPlaceholder": "paste your API key", + "models.form.apiKeyNotNeeded": "not required (local server)", + "models.form.apiKeyHint": "Stored securely on the gateway; it is not shown again.", + "models.form.keyStored": "A key is saved — leave blank to keep it, or paste a new one.", + "models.form.keyFromEnv": "Using a key from an environment variable.", + "models.form.testKey": "Test & load models", + "models.form.testing": "checking…", + "models.form.foundModels": "{n} models loaded", + "models.form.modelPlaceholder": "type to search…", + "models.form.modelHint": "Click “Test & load models” to pull the list from your key, then pick one.", "models.managed": "managed", "models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.", diff --git a/web/js/i18n/ru.js b/web/js/i18n/ru.js index fe30186..eed34d9 100644 --- a/web/js/i18n/ru.js +++ b/web/js/i18n/ru.js @@ -128,6 +128,17 @@ export default { "models.form.secretMissing": "не задан", "models.form.secretPlaceholder": "задать или заменить ключ (только запись)", "models.deleteConfirm": "Удалить модель \"{id}\"?", + "models.form.apiKey": "API-ключ", + "models.form.apiKeyPlaceholder": "вставьте API-ключ", + "models.form.apiKeyNotNeeded": "не нужен (локальный сервер)", + "models.form.apiKeyHint": "Хранится безопасно на шлюзе; больше не показывается.", + "models.form.keyStored": "Ключ сохранён — оставьте пустым, чтобы не менять, или вставьте новый.", + "models.form.keyFromEnv": "Используется ключ из переменной окружения.", + "models.form.testKey": "Проверить и загрузить модели", + "models.form.testing": "проверка…", + "models.form.foundModels": "загружено моделей: {n}", + "models.form.modelPlaceholder": "введите для поиска…", + "models.form.modelHint": "Нажмите «Проверить и загрузить модели», чтобы подтянуть список по ключу, затем выберите.", "models.managed": "управляемая", "models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.", diff --git a/web/js/views/models.js b/web/js/views/models.js index 3a964b2..a2aea5a 100644 --- a/web/js/views/models.js +++ b/web/js/views/models.js @@ -16,52 +16,124 @@ function keyBadge(src) { return h("span", { class: "badge " + (map[src] ?? "") }, src); } +// Verified per-provider defaults (base URL, key env, whether a key is needed). +const PROVIDER_DEFAULTS = { + openai: { base: "https://api.openai.com/v1", keyEnv: "OPENAI_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] }, + anthropic: { base: "https://api.anthropic.com", keyEnv: "ANTHROPIC_API_KEY", needsKey: true, tier: "expensive", caps: ["tools", "vision"] }, + openrouter: { base: "https://openrouter.ai/api/v1", keyEnv: "OPENROUTER_API_KEY", needsKey: true, tier: "mid", caps: ["tools", "vision"] }, + lmstudio: { base: "http://localhost:1234/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools", "vision"] }, + ollama: { base: "http://localhost:11434/v1", keyEnv: "", needsKey: false, tier: "local", caps: ["tools"] }, + http: { base: "", keyEnv: "", needsKey: false, tier: "cheap", caps: ["tools"] }, +}; + function modelForm(meta, existing) { - const m = existing || { provider: "openai", kind: "chat", cost_tier: "cheap", caps: [], price_in: 0, price_out: 0 }; - const idIn = h("input", { value: m.id || "", placeholder: "local-qwen", disabled: existing ? true : null }); + 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 providerSel = h("select", {}, ...(meta.providers || ["openai"]).map((p) => opt(p, p, p === m.provider))); - const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "http://localhost:8000/v1" }); - const modelIn = h("input", { value: m.model || "", placeholder: "qwen2.5-7b-instruct" }); + const baseUrlIn = h("input", { value: m.base_url || "", placeholder: "https://api.openai.com/v1" }); + + // API key — typed directly, stored securely as a secret under a provider env name. + const keyIn = h("input", { type: "password", placeholder: t("models.form.apiKeyPlaceholder"), autocomplete: "new-password" }); + const testBtn = h("button", { class: "btn small", type: "button" }, t("models.form.testKey")); + const keyStatus = h("span", { class: "small", style: "margin-left:8px" }); + + // Model — below the key (some providers list models from the key); searchable datalist. + const dlId = "model-opts-" + (m.id || "new"); + const dl = h("datalist", { id: dlId }); + if (m.model) dl.appendChild(h("option", { value: m.model })); + const modelIn = h("input", { value: m.model || "", placeholder: t("models.form.modelPlaceholder"), list: dlId, autocomplete: "off" }); + const kindSel = h("select", {}, ...(meta.kinds || ["chat"]).map((k) => opt(k, k, k === m.kind))); 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 keyEnvIn = h("input", { value: m.api_key_env || "", placeholder: "OPENAI_API_KEY" }); - const secretIn = h("input", { type: "password", placeholder: t("models.form.secretPlaceholder") }); const capInputs = (meta.caps || ["tools", "vision"]).map((c) => { const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null }); return { c, cb }; }); + const envFor = () => + (existing && existing.api_key_env) || + (PROVIDER_DEFAULTS[providerSel.value] || {}).keyEnv || + providerSel.value.toUpperCase().replace(/[^A-Z0-9]/g, "_") + "_API_KEY"; + + // Fill in a provider's verified defaults when it is selected. + function applyDefaults() { + const d = PROVIDER_DEFAULTS[providerSel.value]; + if (!d) return; + baseUrlIn.value = d.base; + tierSel.value = d.tier; + capInputs.forEach(({ c, cb }) => { cb.checked = d.caps.includes(c); }); + keyIn.placeholder = d.needsKey ? t("models.form.apiKeyPlaceholder") : t("models.form.apiKeyNotNeeded"); + mount(keyStatus, ""); + } + providerSel.addEventListener("change", applyDefaults); + if (!existing) applyDefaults(); + + async function testKey() { + if (!baseUrlIn.value.trim()) { mount(keyStatus, h("span", { style: "color:var(--bad)" }, t("models.form.baseUrl") + "?")); return; } + testBtn.disabled = true; + mount(keyStatus, t("models.form.testing")); + try { + const r = await api.providerModels({ + provider: providerSel.value, + base_url: baseUrlIn.value.trim(), + api_key: keyIn.value.trim() || null, + 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(); + } else { + 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))); + } + testBtn.disabled = false; + } + testBtn.addEventListener("click", testKey); + + const keyHint = + existing && existing.key_source === "store" ? t("models.form.keyStored") + : existing && existing.key_source === "env" ? t("models.form.keyFromEnv") + : t("models.form.apiKeyHint"); + const node = h( "div", {}, h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)), field(t("models.form.baseUrl"), baseUrlIn), - field(t("models.form.model"), modelIn), + field(t("models.form.apiKey"), h("div", { class: "flex wrap", style: "gap:8px;align-items:center" }, keyIn, testBtn, keyStatus), keyHint), + field(t("models.form.model"), modelIn, t("models.form.modelHint")), + 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.apiKeyEnv"), keyEnvIn, t("models.form.apiKeyEnvHint")), - field(t("models.form.secret"), secretIn) + field(t("models.form.caps"), h("div", { class: "flex wrap" }, ...capInputs.map(({ c, cb }) => h("label", { class: "check" }, cb, c)))) ); - const getValue = () => ({ - model: { - id: existing ? m.id : idIn.value.trim(), - provider: providerSel.value, - base_url: baseUrlIn.value.trim(), - model: modelIn.value.trim(), - kind: kindSel.value, - cost_tier: tierSel.value, - price_in: parseFloat(priceInIn.value) || 0, - price_out: parseFloat(priceOutIn.value) || 0, - api_key_env: keyEnvIn.value.trim() || null, - caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c), - }, - secret: secretIn.value, - keyEnv: keyEnvIn.value.trim(), - }); + const getValue = () => { + const typedKey = keyIn.value.trim(); + const hasKey = !!typedKey || !!(existing && existing.api_key_env); + const keyEnv = envFor(); + return { + model: { + id: existing ? m.id : idIn.value.trim(), + provider: providerSel.value, + base_url: baseUrlIn.value.trim(), + model: modelIn.value.trim(), + kind: kindSel.value, + cost_tier: tierSel.value, + price_in: parseFloat(priceInIn.value) || 0, + price_out: parseFloat(priceOutIn.value) || 0, + api_key_env: hasKey ? keyEnv : null, + caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c), + }, + secret: typedKey, + keyEnv, + }; + }; return { node, getValue }; } @@ -74,8 +146,7 @@ async function openModal(meta, existing, reload) { toast(t("models.form.id") + "?", "bad"); return false; } - // strip null api_key_env so serde keeps it optional - if (!model.api_key_env) delete model.api_key_env; + if (!model.api_key_env) delete model.api_key_env; // keep serde field optional try { if (existing) await api.updateModel(existing.id, model); else await api.createModel(model);