Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f00ee502 |
Generated
+1
-1
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cortiq-gateway"
|
name = "cortiq-gateway"
|
||||||
version = "0.2.9"
|
version = "0.2.10"
|
||||||
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.9"
|
version = "0.2.10"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+92
-4
@@ -179,6 +179,7 @@ pub fn api_routes(admin_token: String) -> Router<SharedState> {
|
|||||||
.route("/admin/api/cmf/install", post(cmf_install))
|
.route("/admin/api/cmf/install", post(cmf_install))
|
||||||
.route("/admin/api/cmf/port", get(cmf_port_check))
|
.route("/admin/api/cmf/port", get(cmf_port_check))
|
||||||
.route("/admin/api/cmf/files", get(cmf_files))
|
.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/requests", get(get_requests))
|
||||||
.route("/admin/api/test", post(run_test))
|
.route("/admin/api/test", post(run_test))
|
||||||
.route("/admin/api/test/stream", post(run_test_stream))
|
.route("/admin/api/test/stream", post(run_test_stream))
|
||||||
@@ -391,18 +392,105 @@ async fn router_probe(State(state): State<SharedState>) -> ApiResult {
|
|||||||
ok(json!({ "ok": true, "status": "ok", "latency_ms": latency_ms, "usage": usage }))
|
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<String>,
|
||||||
|
/// …or the name of a stored secret to reuse if no key was typed.
|
||||||
|
#[serde(default)]
|
||||||
|
api_key_env: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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::<Value>(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<SharedState>,
|
||||||
|
Json(b): Json<ProviderModelsBody>,
|
||||||
|
) -> 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<String> = 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 {
|
async fn meta() -> ApiResult {
|
||||||
ok(json!({
|
ok(json!({
|
||||||
"version": env!("CARGO_PKG_VERSION"),
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
"providers": ["openai", "anthropic", "ollama", "http"],
|
"providers": ["openai", "anthropic", "openrouter", "lmstudio", "ollama", "http"],
|
||||||
"provider_impl": { "openai": true, "ollama": true, "http": true, "anthropic": true },
|
"provider_impl": { "openai": true, "anthropic": true, "openrouter": true, "lmstudio": true, "ollama": true, "http": true },
|
||||||
"cost_tiers": ["cheap", "mid", "expensive"],
|
"cost_tiers": ["cheap", "mid", "expensive", "local"],
|
||||||
"kinds": ["chat", "embedding"],
|
"kinds": ["chat", "embedding"],
|
||||||
"profiles": ["cost-saver", "balanced", "quality-first"],
|
"profiles": ["cost-saver", "balanced", "quality-first"],
|
||||||
"text_strategies": ["last_user", "last_user_plus_system", "concat_all"],
|
"text_strategies": ["last_user", "last_user_plus_system", "concat_all"],
|
||||||
"policy_modes": ["fixed_table", "cost_aware"],
|
"policy_modes": ["fixed_table", "cost_aware"],
|
||||||
"tiers": ["low", "medium", "high"],
|
"tiers": ["low", "medium", "high"],
|
||||||
"caps": ["tools", "vision"],
|
"caps": ["tools", "vision", "audio", "reasoning", "json", "caching"],
|
||||||
"protocols_impl": {
|
"protocols_impl": {
|
||||||
"openai_chat": true, "openai_completions": true, "openai_embeddings": true,
|
"openai_chat": true, "openai_completions": true, "openai_embeddings": true,
|
||||||
"openai_models": true, "anthropic_messages": true, "mcp": true, "native_passthrough": true
|
"openai_models": true, "anthropic_messages": true, "mcp": true, "native_passthrough": true
|
||||||
|
|||||||
+4
-1
@@ -21,7 +21,10 @@ impl Registry {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|name| secrets.resolve(name));
|
.and_then(|name| secrets.resolve(name));
|
||||||
let provider: Arc<dyn Provider> = match m.provider.as_str() {
|
let provider: Arc<dyn Provider> = 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)?),
|
"anthropic" => Arc::new(AnthropicProvider::new(m, api_key)?),
|
||||||
other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id),
|
other => anyhow::bail!("unknown provider '{}' for model '{}'", other, m.id),
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+1
@@ -100,6 +100,7 @@ export const api = {
|
|||||||
cmfInstall: () => j("POST", "/cmf/install"),
|
cmfInstall: () => j("POST", "/cmf/install"),
|
||||||
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
cmfPortCheck: (port) => j("GET", "/cmf/port?port=" + encodeURIComponent(port)),
|
||||||
cmfFiles: () => j("GET", "/cmf/files"),
|
cmfFiles: () => j("GET", "/cmf/files"),
|
||||||
|
providerModels: (b) => j("POST", "/provider/models", b),
|
||||||
|
|
||||||
// CMF model factory (HuggingFace → local .cmf)
|
// CMF model factory (HuggingFace → local .cmf)
|
||||||
hfSearch: (q, limit = 24) =>
|
hfSearch: (q, limit = 24) =>
|
||||||
|
|||||||
Vendored
+11
@@ -129,6 +129,17 @@ 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.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.managed": "managed",
|
||||||
"models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.",
|
"models.managedHint": "Provided by the managed CMF server (Settings → Local models). Configure it there.",
|
||||||
|
|
||||||
|
|||||||
Vendored
+11
@@ -128,6 +128,17 @@ export default {
|
|||||||
"models.form.secretMissing": "не задан",
|
"models.form.secretMissing": "не задан",
|
||||||
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
"models.form.secretPlaceholder": "задать или заменить ключ (только запись)",
|
||||||
"models.deleteConfirm": "Удалить модель \"{id}\"?",
|
"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.managed": "управляемая",
|
||||||
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
"models.managedHint": "Предоставляется CMF-сервером (Настройки → Локальные модели). Настраивается там.",
|
||||||
|
|
||||||
|
|||||||
Vendored
+99
-28
@@ -16,52 +16,124 @@ function keyBadge(src) {
|
|||||||
return h("span", { class: "badge " + (map[src] ?? "") }, 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) {
|
function modelForm(meta, existing) {
|
||||||
const m = existing || { provider: "openai", kind: "chat", cost_tier: "cheap", caps: [], 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: "local-qwen", disabled: existing ? true : null });
|
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 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 baseUrlIn = h("input", { value: m.base_url || "", placeholder: "https://api.openai.com/v1" });
|
||||||
const modelIn = h("input", { value: m.model || "", placeholder: "qwen2.5-7b-instruct" });
|
|
||||||
|
// 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 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 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 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 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 capInputs = (meta.caps || ["tools", "vision"]).map((c) => {
|
||||||
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
|
const cb = h("input", { type: "checkbox", checked: (m.caps || []).includes(c) ? true : null });
|
||||||
return { c, cb };
|
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(
|
const node = h(
|
||||||
"div",
|
"div",
|
||||||
{},
|
{},
|
||||||
h("div", { class: "row" }, field(t("models.form.id"), idIn, existing ? null : t("models.form.idHint")), field(t("models.form.provider"), providerSel)),
|
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.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.kind"), kindSel), field(t("models.form.costTier"), tierSel)),
|
||||||
h("div", { class: "row" }, field(t("models.form.priceIn"), priceInIn), field(t("models.form.priceOut"), priceOutIn)),
|
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" }, ...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)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getValue = () => ({
|
const getValue = () => {
|
||||||
model: {
|
const typedKey = keyIn.value.trim();
|
||||||
id: existing ? m.id : idIn.value.trim(),
|
const hasKey = !!typedKey || !!(existing && existing.api_key_env);
|
||||||
provider: providerSel.value,
|
const keyEnv = envFor();
|
||||||
base_url: baseUrlIn.value.trim(),
|
return {
|
||||||
model: modelIn.value.trim(),
|
model: {
|
||||||
kind: kindSel.value,
|
id: existing ? m.id : idIn.value.trim(),
|
||||||
cost_tier: tierSel.value,
|
provider: providerSel.value,
|
||||||
price_in: parseFloat(priceInIn.value) || 0,
|
base_url: baseUrlIn.value.trim(),
|
||||||
price_out: parseFloat(priceOutIn.value) || 0,
|
model: modelIn.value.trim(),
|
||||||
api_key_env: keyEnvIn.value.trim() || null,
|
kind: kindSel.value,
|
||||||
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
cost_tier: tierSel.value,
|
||||||
},
|
price_in: parseFloat(priceInIn.value) || 0,
|
||||||
secret: secretIn.value,
|
price_out: parseFloat(priceOutIn.value) || 0,
|
||||||
keyEnv: keyEnvIn.value.trim(),
|
api_key_env: hasKey ? keyEnv : null,
|
||||||
});
|
caps: capInputs.filter(({ cb }) => cb.checked).map(({ c }) => c),
|
||||||
|
},
|
||||||
|
secret: typedKey,
|
||||||
|
keyEnv,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return { node, getValue };
|
return { node, getValue };
|
||||||
}
|
}
|
||||||
@@ -74,8 +146,7 @@ async function openModal(meta, existing, reload) {
|
|||||||
toast(t("models.form.id") + "?", "bad");
|
toast(t("models.form.id") + "?", "bad");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// strip null api_key_env so serde keeps it optional
|
if (!model.api_key_env) delete model.api_key_env; // keep serde field optional
|
||||||
if (!model.api_key_env) delete model.api_key_env;
|
|
||||||
try {
|
try {
|
||||||
if (existing) await api.updateModel(existing.id, model);
|
if (existing) await api.updateModel(existing.id, model);
|
||||||
else await api.createModel(model);
|
else await api.createModel(model);
|
||||||
|
|||||||
Reference in New Issue
Block a user