Compare commits

...
Author SHA1 Message Date
施工员andmultica-agent 4109723655 feat: one LLM provider holds many models + fetch provider model list (HEL-624)
- config: llm_providers {id,name,base_url,api_key}; each llm_profile is one
  model with provider_id, endpoint/key mirrored from its provider so every
  existing reader (LLMClient, role resolution, llm-test) is unchanged
- legacy one-model-per-profile configs migrate to one provider each on load
  (ids, default model and agent assignments kept) and are persisted
- POST /api/settings accepts llm_providers; blank key keeps the stored one
- POST /api/settings/llm-models: GET {base}/models (OpenAI data[] and
  models[] shapes), timeout/connect/auth/unsupported/format errors
- models page: provider cards with per-model rows (test / set default /
  delete), provider form with fetch-and-tick list + manual add, agent
  bindings grouped by provider; agents page select grouped likewise

Co-authored-by: multica-agent <github@multica.ai>
2026-09-28 18:11:11 +08:00
14 changed files with 1524 additions and 295 deletions
+198 -11
View File
@@ -127,6 +127,11 @@ class Settings(BaseSettings):
# profile so LLMClient and env overrides keep working.
llm_profiles: list[dict[str, Any]] = Field(default_factory=list)
llm_active_id: str | None = None
# Providers group profiles that share one endpoint + key (HEL-624). Each
# profile is still one model and carries `provider_id`; its base_url /
# api_key always mirror the provider so profile readers stay unchanged.
# Each provider: {id, name, base_url, api_key}.
llm_providers: list[dict[str, Any]] = Field(default_factory=list)
# role (AGENT_LLM_ROLES) -> LLM profile id; None/absent = use Active LLM
agent_llm_assignments: dict[str, str | None] = Field(default_factory=dict)
@@ -199,6 +204,7 @@ class Settings(BaseSettings):
"base_url": self.llm_base_url,
"model": self.llm_model,
"api_key": self.llm_api_key,
"provider_id": None,
}
]
self.llm_active_id = pid
@@ -222,6 +228,7 @@ class Settings(BaseSettings):
"base_url": base_url or self.llm_base_url,
"model": model or "llama3.2",
"api_key": key,
"provider_id": item.get("provider_id") or None,
}
)
if not normalized:
@@ -232,9 +239,166 @@ class Settings(BaseSettings):
if self.llm_active_id not in ids:
self.llm_active_id = normalized[0]["id"]
migrated = True
if self._sync_llm_providers():
migrated = True
self.apply_active_llm()
return migrated
def _sync_llm_providers(self) -> bool:
"""Make every profile point at a provider with its exact endpoint + key.
Profiles are the source of truth here: a profile without a provider
(pre-HEL-624 config) gets its own provider, so existing ids and agent
assignments are untouched. A profile whose endpoint drifted from its
provider (legacy flat save) updates the provider when it is the only
member, otherwise splits off into a new one. Empty providers are
dropped. Returns True when anything changed.
"""
changed = False
providers: list[dict[str, Any]] = []
by_id: dict[str, dict[str, Any]] = {}
for raw in self.llm_providers if isinstance(self.llm_providers, list) else []:
if not isinstance(raw, dict) or not raw.get("id") or raw["id"] in by_id:
changed = True
continue
key = raw.get("api_key")
prov = {
"id": str(raw["id"]),
"name": str(raw.get("name") or "").strip(),
"base_url": str(raw.get("base_url") or "").strip(),
"api_key": key if isinstance(key, str) and key.strip() else None,
}
providers.append(prov)
by_id[prov["id"]] = prov
members: dict[str, int] = {}
for prof in self.llm_profiles:
pid = prof.get("provider_id")
if pid in by_id:
members[pid] = members.get(pid, 0) + 1
for prof in self.llm_profiles:
prov = by_id.get(prof.get("provider_id") or "")
if prov is not None and (
prov["base_url"] == prof["base_url"] and prov["api_key"] == prof["api_key"]
):
continue
changed = True
if prov is not None and members.get(prov["id"], 0) == 1:
prov["base_url"] = prof["base_url"]
prov["api_key"] = prof["api_key"]
continue
if prov is not None:
members[prov["id"]] -= 1
prov = {
"id": str(uuid.uuid4()),
"name": str(prof.get("name") or "").strip() or prof["base_url"],
"base_url": prof["base_url"],
"api_key": prof["api_key"],
}
providers.append(prov)
by_id[prov["id"]] = prov
members[prov["id"]] = 1
prof["provider_id"] = prov["id"]
kept = [p for p in providers if members.get(p["id"], 0) > 0]
for prov in kept:
if not prov["name"]:
prov["name"] = prov["base_url"]
changed = True
if len(kept) != len(providers):
changed = True
self.llm_providers = kept
return changed
def _after_llm_list_change(self) -> None:
ids = {p["id"] for p in self.llm_profiles}
if self.llm_active_id not in ids:
self.llm_active_id = self.llm_profiles[0]["id"]
self.agent_llm_assignments = {
role: pid
for role, pid in (self.agent_llm_assignments or {}).items()
if pid is None or pid in ids
}
self.apply_active_llm()
def replace_llm_providers(self, incoming: list[dict[str, Any]]) -> None:
"""Replace providers and their models in one go (models page save).
Each incoming provider: {id?, name, base_url, api_key?, models: [{id?,
name?, model}]}. A blank key keeps the stored one; model ids are kept
so agent assignments and the default model survive edits.
"""
old_providers = {
str(p["id"]): p
for p in (self.llm_providers or [])
if isinstance(p, dict) and p.get("id")
}
old_profiles = {
str(p["id"]): p
for p in (self.llm_profiles or [])
if isinstance(p, dict) and p.get("id")
}
providers: list[dict[str, Any]] = []
profiles: list[dict[str, Any]] = []
used_ids: set[str] = set()
for raw in incoming:
if not isinstance(raw, dict):
continue
prov_id = str(raw.get("id") or uuid.uuid4())
if prov_id in used_ids:
prov_id = str(uuid.uuid4())
used_ids.add(prov_id)
old = old_providers.get(prov_id, {})
base_url = str(raw.get("base_url") or old.get("base_url") or "").strip()
if not base_url:
raise ValueError("服务商接口地址不能为空")
api_key = old.get("api_key") if isinstance(old.get("api_key"), str) else None
incoming_key = raw.get("api_key")
if isinstance(incoming_key, str) and incoming_key.strip():
api_key = incoming_key.strip()
name = str(raw.get("name") or old.get("name") or base_url).strip()
seen_models: set[str] = set()
count = 0
for m in raw.get("models") or []:
if not isinstance(m, dict):
continue
model = str(m.get("model") or "").strip()
if not model or model in seen_models:
continue
seen_models.add(model)
mid = str(m.get("id") or uuid.uuid4())
if mid in used_ids:
mid = str(uuid.uuid4())
used_ids.add(mid)
prev_name = old_profiles.get(mid, {}).get("name")
profiles.append(
{
"id": mid,
"name": str(m.get("name") or prev_name or model).strip() or model,
"base_url": base_url,
"model": model,
"api_key": api_key,
"provider_id": prov_id,
}
)
count += 1
if count == 0:
raise ValueError(f"服务商「{name}」至少需要一个模型")
providers.append(
{"id": prov_id, "name": name, "base_url": base_url, "api_key": api_key}
)
if not profiles:
raise ValueError("At least one LLM is required")
self.llm_providers = providers
self.llm_profiles = profiles
self._after_llm_list_change()
def resolve_llm_provider(self, provider_id: str) -> dict[str, Any] | None:
"""Full provider dict (key included). Server-side use only."""
self.ensure_llm_profiles()
return next((p for p in self.llm_providers if p.get("id") == provider_id), None)
def apply_active_llm(self) -> None:
"""Copy the active profile onto llm_base_url / llm_model / llm_api_key."""
profiles = self.llm_profiles if isinstance(self.llm_profiles, list) else []
@@ -293,20 +457,14 @@ class Settings(BaseSettings):
"base_url": base_url or self.llm_base_url,
"model": model or "llama3.2",
"api_key": api_key,
"provider_id": raw.get("provider_id") or old.get("provider_id") or None,
}
)
if not new_list:
raise ValueError("At least one LLM is required")
self.llm_profiles = new_list
ids = {p["id"] for p in new_list}
if self.llm_active_id not in ids:
self.llm_active_id = new_list[0]["id"]
self.agent_llm_assignments = {
role: pid
for role, pid in (self.agent_llm_assignments or {}).items()
if pid is None or pid in ids
}
self.apply_active_llm()
self._sync_llm_providers()
self._after_llm_list_change()
def sync_legacy_llm_into_active(self) -> None:
"""Write current llm_* fields onto the active profile (legacy PATCH)."""
@@ -322,6 +480,7 @@ class Settings(BaseSettings):
if not str(profile.get("name") or "").strip():
profile["name"] = model
break
self._sync_llm_providers()
self.apply_active_llm()
# ------------------------------------------------------------------
@@ -487,6 +646,7 @@ class Settings(BaseSettings):
def _public_llm_profiles(self) -> list[dict[str, Any]]:
self.ensure_llm_profiles()
names = {p["id"]: p["name"] for p in self.llm_providers}
return [
{
"id": p["id"],
@@ -497,13 +657,37 @@ class Settings(BaseSettings):
# Last 4 characters only — enough to recognise a saved key in
# the UI without ever exposing it (GitHub/Stripe convention).
"api_key_tail": (p.get("api_key") or "")[-4:] or None,
"provider_id": p.get("provider_id"),
"provider_name": names.get(p.get("provider_id") or ""),
}
for p in self.llm_profiles
]
def _public_llm_providers(self) -> list[dict[str, Any]]:
self.ensure_llm_profiles()
out: list[dict[str, Any]] = []
for prov in self.llm_providers:
key = prov.get("api_key") if isinstance(prov.get("api_key"), str) else None
out.append(
{
"id": prov["id"],
"name": prov["name"],
"base_url": prov["base_url"],
"api_key": bool(key),
"api_key_tail": (key or "")[-4:] or None,
"models": [
{"id": p["id"], "name": p["name"], "model": p["model"]}
for p in self.llm_profiles
if p.get("provider_id") == prov["id"]
],
}
)
return out
def to_public_dict(self) -> dict[str, Any]:
"""Return settings that are safe to expose to the frontend."""
profiles = self._public_llm_profiles()
providers = self._public_llm_providers()
return {
"host": self.host,
"port": self.port,
@@ -515,6 +699,7 @@ class Settings(BaseSettings):
"llm_model": self.llm_model,
"llm_api_key": bool(self.llm_api_key),
"llm_profiles": profiles,
"llm_providers": providers,
"llm_active_id": self.llm_active_id,
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
"comfyui_base_url": self.comfyui_base_url,
@@ -579,8 +764,9 @@ class Settings(BaseSettings):
self.ensure_storage_dirs()
profiles_migrated = self.ensure_llm_profiles()
comfy_migrated = self.ensure_comfyui_endpoints()
if comfy_migrated:
# Legacy single-address config: persist the migrated endpoint list.
if comfy_migrated or (profiles_migrated and "llm_providers" not in data):
# Legacy single-address / one-model-per-profile config: persist the
# migrated lists so generated ids stay stable across restarts.
self.save_config_file()
# Auto-heal poisoned config (temp paths / quoted dirs / legacy comfy dirs) back to disk
@@ -616,6 +802,7 @@ class Settings(BaseSettings):
"llm_model": self.llm_model,
"llm_api_key": self.llm_api_key,
"llm_profiles": self.llm_profiles,
"llm_providers": self.llm_providers,
"llm_active_id": self.llm_active_id,
"agent_llm_assignments": dict(self.agent_llm_assignments or {}),
"comfyui_base_url": self.comfyui_base_url,
@@ -17,16 +17,44 @@ _LLM_TEST_TIMEOUT_SEC = 15.0
_LLM_TEST_TIMEOUT_RANGE = (5, 60)
_LLM_MODELS_MAX = 500
class LlmProfileIn(BaseModel):
id: str | None = None
name: str | None = None
base_url: str | None = None
model: str | None = None
api_key: str | None = None
provider_id: str | None = None
class LlmProviderModelIn(BaseModel):
id: str | None = None
name: str | None = None
model: str
class LlmProviderIn(BaseModel):
id: str | None = None
name: str | None = None
base_url: str | None = None
api_key: str | None = None
models: list[LlmProviderModelIn] = Field(default_factory=list)
class LlmModelsIn(BaseModel):
provider_id: str | None = None
base_url: str | None = None
api_key: str | None = None
timeout_sec: float | None = Field(
None, ge=_LLM_TEST_TIMEOUT_RANGE[0], le=_LLM_TEST_TIMEOUT_RANGE[1]
)
class LlmTestIn(BaseModel):
profile_id: str | None = None
provider_id: str | None = None
base_url: str | None = None
model: str | None = None
api_key: str | None = None
@@ -42,6 +70,7 @@ class SettingsUpdate(BaseModel):
llm_model: str | None = None
llm_api_key: str | None = None
llm_profiles: list[LlmProfileIn] | None = None
llm_providers: list[LlmProviderIn] | None = None
llm_active_id: str | None = None
agent_llm_assignments: dict[str, str | None] | None = None
comfyui_base_url: str | None = None
@@ -70,6 +99,7 @@ async def get_settings() -> dict[str, Any]:
async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
data = payload.model_dump(exclude_unset=True)
profiles_in = data.pop("llm_profiles", None)
providers_in = data.pop("llm_providers", None)
active_in = data.pop("llm_active_id", None)
assignments_in = data.pop("agent_llm_assignments", None)
legacy_llm = {k: data.pop(k) for k in list(data) if k in _LEGACY_LLM_KEYS}
@@ -96,6 +126,12 @@ async def update_settings(payload: SettingsUpdate) -> dict[str, Any]:
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if providers_in is not None:
try:
settings.replace_llm_providers(providers_in)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if active_in is not None:
settings.ensure_llm_profiles()
ids = {p["id"] for p in settings.llm_profiles}
@@ -162,6 +198,8 @@ async def test_llm_connection(payload: LlmTestIn) -> dict[str, Any]:
key = profile.get("api_key")
if isinstance(key, str) and key.strip():
api_key = key
elif payload.provider_id:
base_url, api_key = _provider_fallback(payload.provider_id, base_url, api_key)
if not base_url or not model:
raise HTTPException(status_code=400, detail="base_url and model are required")
@@ -213,3 +251,86 @@ async def test_llm_connection(payload: LlmTestIn) -> dict[str, Any]:
if not (isinstance(data, dict) and data.get("choices")):
return {"ok": False, "error": "http", "detail": "response has no choices"}
return {"ok": True, "elapsed_ms": elapsed_ms}
def _provider_fallback(
provider_id: str, base_url: str, api_key: str | None
) -> tuple[str, str | None]:
"""Fill a blank address / key from a saved provider (key never leaves the server)."""
provider = settings.resolve_llm_provider(provider_id)
if provider is None:
raise HTTPException(status_code=404, detail="Unknown LLM provider")
if not base_url:
base_url = str(provider.get("base_url") or "").strip()
if api_key is None:
key = provider.get("api_key")
if isinstance(key, str) and key.strip():
api_key = key
return base_url, api_key
def _parse_model_ids(data: Any) -> list[str] | None:
"""Model ids from an OpenAI `{data: [{id}]}` list (or the common
`{models: [{name}]}` / bare-list variants). None = not a model list."""
items: Any = None
if isinstance(data, dict):
for key in ("data", "models"):
if isinstance(data.get(key), list):
items = data[key]
break
elif isinstance(data, list):
items = data
if items is None:
return None
ids: list[str] = []
for item in items:
value: Any = item
if isinstance(item, dict):
value = item.get("id") or item.get("name") or item.get("model")
if isinstance(value, str) and value.strip() and value.strip() not in ids:
ids.append(value.strip())
return sorted(ids, key=str.lower)[:_LLM_MODELS_MAX]
@router.post("/llm-models")
async def list_llm_models(payload: LlmModelsIn) -> dict[str, Any]:
"""List the models an OpenAI-compatible provider offers (GET {base}/models)."""
base_url = (payload.base_url or "").strip()
api_key = (payload.api_key or "").strip() or None
if payload.provider_id:
base_url, api_key = _provider_fallback(payload.provider_id, base_url, api_key)
if not base_url:
raise HTTPException(status_code=400, detail="base_url is required")
headers = {"Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
timeout_sec = payload.timeout_sec if payload.timeout_sec is not None else _LLM_TEST_TIMEOUT_SEC
timeout_sec = min(max(timeout_sec, _LLM_TEST_TIMEOUT_RANGE[0]), _LLM_TEST_TIMEOUT_RANGE[1])
try:
async with httpx.AsyncClient(timeout=timeout_sec) as client:
resp = await client.get(f"{base_url.rstrip('/')}/models", headers=headers)
except httpx.TimeoutException:
return {
"ok": False,
"error": "timeout",
"detail": f"no response within {timeout_sec:.0f}s",
"timeout_sec": timeout_sec,
}
except httpx.HTTPError as exc:
return {"ok": False, "error": "connect", "detail": str(exc) or exc.__class__.__name__}
if resp.status_code in (401, 403):
return {"ok": False, "error": "auth", "detail": f"HTTP {resp.status_code}"}
if resp.status_code in (404, 405, 501):
return {"ok": False, "error": "unsupported", "detail": f"HTTP {resp.status_code}"}
if resp.status_code >= 400:
text = resp.text[:200].strip().replace("\n", " ")
return {"ok": False, "error": "http", "detail": f"HTTP {resp.status_code}: {text}"}
try:
data = resp.json()
except ValueError:
return {"ok": False, "error": "format", "detail": "response is not JSON"}
ids = _parse_model_ids(data)
if ids is None:
return {"ok": False, "error": "format", "detail": "response has no model list"}
return {"ok": True, "models": ids}
@@ -0,0 +1,366 @@
"""One provider (endpoint + key) holding several models, and model-list discovery."""
from __future__ import annotations
import json
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
import calliope.config as config_module
from calliope.config import Settings, settings
P1 = "11111111-1111-1111-1111-111111111111"
M1 = "aaaaaaaa-0000-0000-0000-000000000001"
M2 = "aaaaaaaa-0000-0000-0000-000000000002"
class _FakeModelsHandler(BaseHTTPRequestHandler):
def do_GET(self):
srv = self.server
srv.requests.append({"path": self.path, "auth": self.headers.get("Authorization") or ""})
if srv.delay:
time.sleep(srv.delay)
if srv.mode == "unsupported" or self.path != "/v1/models":
self._send(404, {"error": "not found"})
return
if srv.api_key and self.headers.get("Authorization") != f"Bearer {srv.api_key}":
self._send(401, {"error": {"message": "Incorrect API key"}})
return
if srv.mode == "garbage":
self._send(200, {"hello": "world"})
return
if srv.mode == "ollama":
self._send(200, {"models": [{"name": "qwen3:8b"}, {"name": "llama3.2"}]})
return
self._send(
200,
{
"object": "list",
"data": [
{"id": "gpt-4o-mini", "object": "model"},
{"id": "deepseek-chat", "object": "model"},
{"id": "deepseek-chat", "object": "model"},
{"id": "Qwen3-32B", "object": "model"},
],
},
)
def _send(self, status: int, payload: dict) -> None:
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, format, *args):
pass
@pytest.fixture
def fake_models():
server = ThreadingHTTPServer(("127.0.0.1", 0), _FakeModelsHandler)
server.requests = []
server.api_key = ""
server.mode = "openai"
server.delay = 0.0
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield server
finally:
server.shutdown()
server.server_close()
def _url(server) -> str:
return f"http://127.0.0.1:{server.server_address[1]}/v1"
@pytest.fixture
def llm_state():
keys = (
"llm_profiles",
"llm_providers",
"llm_active_id",
"agent_llm_assignments",
"llm_base_url",
"llm_model",
"llm_api_key",
)
prev = {k: json.loads(json.dumps(getattr(settings, k))) for k in keys}
try:
yield
finally:
for k, v in prev.items():
setattr(settings, k, v)
def _provider_payload(models: list[dict], key: str | None = "sk-prov-7a9c") -> dict:
prov = {
"id": P1,
"name": "DeepSeek",
"base_url": "https://api.example.com/v1",
"models": models,
}
if key is not None:
prov["api_key"] = key
return {"llm_providers": [prov]}
def test_provider_with_many_models_round_trip(client, llm_state):
r = client.post(
"/api/settings",
json={
**_provider_payload(
[{"id": M1, "model": "deepseek-chat"}, {"id": M2, "model": "deepseek-reasoner"}]
),
"llm_active_id": M2,
"agent_llm_assignments": {"story": M1, "video": M2},
},
)
assert r.status_code == 200, r.text
body = r.json()
assert "sk-prov" not in r.text
[prov] = body["llm_providers"]
assert prov["id"] == P1
assert prov["api_key"] is True and prov["api_key_tail"] == "7a9c"
assert [m["model"] for m in prov["models"]] == ["deepseek-chat", "deepseek-reasoner"]
# Flat per-model view keeps working for every existing reader.
assert {p["id"] for p in body["llm_profiles"]} == {M1, M2}
assert all(
p["provider_id"] == P1 and p["provider_name"] == "DeepSeek" for p in body["llm_profiles"]
)
assert body["llm_model"] == "deepseek-reasoner"
assert settings.resolve_llm_for_role("story")["model"] == "deepseek-chat"
assert settings.resolve_llm_for_role("story")["api_key"] == "sk-prov-7a9c"
assert settings.resolve_llm_for_role("video")["model"] == "deepseek-reasoner"
# Re-save without a key: stored key kept for all models; remove M2 → its
# assignment is pruned and the default falls back to a surviving model.
r = client.post(
"/api/settings", json=_provider_payload([{"id": M1, "model": "deepseek-chat"}], key=None)
)
assert r.status_code == 200, r.text
body = r.json()
assert [p["id"] for p in body["llm_profiles"]] == [M1]
assert body["llm_active_id"] == M1
assert body["agent_llm_assignments"] == {"story": M1}
assert settings.llm_profiles[0]["api_key"] == "sk-prov-7a9c"
def test_provider_key_change_applies_to_all_models(client, llm_state):
client.post(
"/api/settings",
json=_provider_payload([{"id": M1, "model": "a"}, {"id": M2, "model": "b"}]),
)
r = client.post(
"/api/settings",
json=_provider_payload(
[{"id": M1, "model": "a"}, {"id": M2, "model": "b"}], key="sk-new-0001"
),
)
assert r.status_code == 200
assert {p["api_key"] for p in settings.llm_profiles} == {"sk-new-0001"}
def test_provider_validation(client, llm_state):
r = client.post("/api/settings", json=_provider_payload([]))
assert r.status_code == 400
assert "至少需要一个模型" in r.json()["detail"]
r = client.post("/api/settings", json={"llm_providers": []})
assert r.status_code == 400
# Duplicate model names inside one provider collapse to one entry.
r = client.post(
"/api/settings",
json=_provider_payload([{"id": M1, "model": "x"}, {"id": M2, "model": "x"}]),
)
assert r.status_code == 200
assert [m["id"] for m in r.json()["llm_providers"][0]["models"]] == [M1]
def test_legacy_profiles_migrate_one_provider_each(client, llm_state):
"""Pre-HEL-624 configs: ids, keys and assignments survive the upgrade."""
settings.llm_providers = []
settings.llm_profiles = [
{
"id": M1,
"name": "本地 Qwen",
"base_url": "http://127.0.0.1:11434/v1",
"model": "qwen3",
"api_key": None,
},
{
"id": M2,
"name": "云端",
"base_url": "https://api.example.com/v1",
"model": "gpt-4o",
"api_key": "sk-old-1234",
},
]
settings.llm_active_id = M2
settings.agent_llm_assignments = {"story": M1, "video": M2}
body = client.get("/api/settings").json()
assert [p["name"] for p in body["llm_providers"]] == ["本地 Qwen", "云端"]
assert [[m["id"] for m in p["models"]] for p in body["llm_providers"]] == [[M1], [M2]]
assert body["llm_providers"][1]["api_key_tail"] == "1234"
assert body["llm_active_id"] == M2
assert body["agent_llm_assignments"] == {"story": M1, "video": M2}
assert settings.resolve_llm_for_role("video")["api_key"] == "sk-old-1234"
# Provider ids are stable on the next read.
again = client.get("/api/settings").json()
assert [p["id"] for p in again["llm_providers"]] == [p["id"] for p in body["llm_providers"]]
def test_legacy_config_file_migrates_and_persists(tmp_path, monkeypatch):
cfg = tmp_path / "calliope_config.json"
cfg.write_text(
json.dumps(
{
"data_dir": str(tmp_path / "data"),
"llm_profiles": [
{
"id": M1,
"name": "A",
"base_url": "http://a/v1",
"model": "m-a",
"api_key": "k-a",
},
{
"id": M2,
"name": "B",
"base_url": "http://b/v1",
"model": "m-b",
"api_key": None,
},
],
"llm_active_id": M2,
"agent_llm_assignments": {"style": M1},
}
),
encoding="utf-8",
)
monkeypatch.setattr(config_module, "CONFIG_FILE", cfg)
monkeypatch.setattr(config_module, "is_ephemeral_path", lambda p: False)
s = Settings()
s.load_config_file()
saved = json.loads(cfg.read_text(encoding="utf-8"))
assert len(saved["llm_providers"]) == 2
assert saved["llm_active_id"] == M2
assert saved["agent_llm_assignments"] == {"style": M1}
by_model = {p["model"]: p for p in saved["llm_profiles"]}
prov_keys = {p["id"]: p["api_key"] for p in saved["llm_providers"]}
assert prov_keys[by_model["m-a"]["provider_id"]] == "k-a"
assert prov_keys[by_model["m-b"]["provider_id"]] is None
# Reload: same provider ids (no churn).
s2 = Settings()
s2.load_config_file()
assert [p["id"] for p in s2.llm_providers] == [p["id"] for p in saved["llm_providers"]]
def test_legacy_flat_save_still_supported(client, llm_state):
client.post(
"/api/settings",
json=_provider_payload([{"id": M1, "model": "a"}, {"id": M2, "model": "b"}]),
)
# Old client edits one model's address via llm_profiles → it splits off.
r = client.post(
"/api/settings",
json={
"llm_profiles": [
{"id": M1, "name": "a", "base_url": "https://api.example.com/v1", "model": "a"},
{"id": M2, "name": "b", "base_url": "http://other/v1", "model": "b"},
]
},
)
assert r.status_code == 200
provs = r.json()["llm_providers"]
assert len(provs) == 2
assert provs[0]["id"] == P1 and [m["id"] for m in provs[0]["models"]] == [M1]
assert provs[1]["base_url"] == "http://other/v1"
def test_list_models_success_dedup_sorted(client, fake_models):
fake_models.api_key = "sk-list"
r = client.post(
"/api/settings/llm-models", json={"base_url": _url(fake_models), "api_key": "sk-list"}
)
assert r.status_code == 200
body = r.json()
assert body == {"ok": True, "models": ["deepseek-chat", "gpt-4o-mini", "Qwen3-32B"]}
assert fake_models.requests[-1] == {"path": "/v1/models", "auth": "Bearer sk-list"}
def test_list_models_ollama_shape(client, fake_models):
fake_models.mode = "ollama"
body = client.post("/api/settings/llm-models", json={"base_url": _url(fake_models)}).json()
assert body == {"ok": True, "models": ["llama3.2", "qwen3:8b"]}
def test_list_models_uses_saved_provider_key(client, fake_models, llm_state):
fake_models.api_key = "sk-saved-9999"
r = client.post(
"/api/settings",
json={
"llm_providers": [
{
"id": P1,
"name": "Fake",
"base_url": _url(fake_models),
"api_key": "sk-saved-9999",
"models": [{"id": M1, "model": "gpt-4o-mini"}],
}
]
},
)
assert r.status_code == 200
body = client.post("/api/settings/llm-models", json={"provider_id": P1}).json()
assert body["ok"] is True
assert fake_models.requests[-1]["auth"] == "Bearer sk-saved-9999"
# Connection test for a not-yet-saved model on a saved provider.
r = client.post("/api/settings/llm-test", json={"provider_id": P1, "model": "new-model"})
assert r.status_code == 200
assert client.post("/api/settings/llm-models", json={"provider_id": "nope"}).status_code == 404
def test_list_models_failures(client, fake_models):
fake_models.api_key = "sk-right"
r = client.post(
"/api/settings/llm-models", json={"base_url": _url(fake_models), "api_key": "sk-wrong"}
)
assert r.json()["ok"] is False and r.json()["error"] == "auth"
fake_models.api_key = ""
fake_models.mode = "unsupported"
r = client.post("/api/settings/llm-models", json={"base_url": _url(fake_models)})
assert r.json()["error"] == "unsupported"
fake_models.mode = "garbage"
r = client.post("/api/settings/llm-models", json={"base_url": _url(fake_models)})
assert r.json()["error"] == "format"
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
r = client.post("/api/settings/llm-models", json={"base_url": f"http://127.0.0.1:{port}/v1"})
assert r.json()["error"] == "connect"
assert client.post("/api/settings/llm-models", json={}).status_code == 400
def test_list_models_timeout(client, fake_models):
fake_models.delay = 6.5
started = time.perf_counter()
r = client.post(
"/api/settings/llm-models", json={"base_url": _url(fake_models), "timeout_sec": 5}
)
assert time.perf_counter() - started < 6.4
body = r.json()
assert body["ok"] is False
assert body["error"] == "timeout"
assert body["timeout_sec"] == 5
+30
View File
@@ -87,6 +87,18 @@ export interface LlmProfile {
api_key: boolean;
/** Last 4 chars of the stored key, for masked display only. */
api_key_tail?: string | null;
provider_id?: string | null;
provider_name?: string | null;
}
/** One endpoint + key holding several models; each model is an LlmProfile id. */
export interface LlmProvider {
id: string;
name: string;
base_url: string;
api_key: boolean;
api_key_tail?: string | null;
models: { id: string; name: string; model: string }[];
}
export interface ComfyEndpoint {
@@ -111,6 +123,7 @@ export interface Settings {
llm_model: string;
llm_api_key: boolean;
llm_profiles: LlmProfile[];
llm_providers: LlmProvider[];
llm_active_id: string | null;
comfyui_base_url: string;
comfyui_endpoints: ComfyEndpoint[];
@@ -348,8 +361,25 @@ export const settings = {
method: "POST",
body: JSON.stringify(payload),
}),
listLlmModels: (payload: {
provider_id?: string;
base_url?: string;
api_key?: string;
timeout_sec?: number;
}) =>
api<{
ok: boolean;
models?: string[];
error?: string;
detail?: string;
timeout_sec?: number;
}>("/api/settings/llm-models", {
method: "POST",
body: JSON.stringify(payload),
}),
testLlm: (payload: {
profile_id?: string;
provider_id?: string;
base_url?: string;
model?: string;
api_key?: string;
+31 -8
View File
@@ -1162,7 +1162,7 @@ export const de: Dict = {
'models.title': 'Modelle',
'models.subtitle': 'Eigene LLMs für Agenten anlegen · Phase 1 nur LLMs; Bild-/Videomodelle folgen später',
'models.statusCount': '{n} Modelle · {m} verfügbar',
'models.add': 'Modell hinzufügen',
'models.add': 'Anbieter hinzufügen',
'models.tabLlm': 'Sprachmodelle',
'models.tabImage': 'Bildmodelle',
'models.tabVideo': 'Videomodelle',
@@ -1186,10 +1186,10 @@ export const de: Dict = {
'models.lastTestAt': 'letzter Test {time}',
'models.consecutiveFails': '{n} Fehlversuche in Folge',
'models.cloudPaused': 'Cloud-Aufrufe pausiert',
'models.formTitleAdd': 'Modell hinzufügen',
'models.formTitleEdit': 'Modell bearbeiten',
'models.formTitleAdd': 'Anbieter hinzufügen',
'models.formTitleEdit': 'Anbieter bearbeiten',
'models.formClose': 'Schließen',
'models.fName': 'Name',
'models.fName': 'Anbietername',
'models.fNamePh': 'Modell benennen, z. B. DeepSeek offiziell',
'models.fApiType': 'API-Typ',
'models.apiOpenai': 'OpenAI-kompatibel',
@@ -1198,7 +1198,7 @@ export const de: Dict = {
'models.apiTypeHelp': 'Phase 1 unterstützt nur OpenAI-kompatible APIs; weitere Protokolle kommen mit Bild-/Videomodellen',
'models.fBaseUrl': 'Basis-URL',
'models.baseUrlHelp': 'Mit Protokoll und Port, z. B. http://192.168.200.18:11434/v1',
'models.fModel': 'Modellname',
'models.fModel': 'Modelle',
'models.modelHelp': 'Modell-ID des Anbieters verwenden',
'models.fApiKey': 'API-Schlüssel',
'models.keyNoEcho': 'wird nie angezeigt',
@@ -1210,10 +1210,10 @@ export const de: Dict = {
'models.testFail': '✕ Test fehlgeschlagen · {reason}',
'models.needTest': 'Speichern erst nach bestandenem Test',
'models.testBtn': 'Verbindung testen',
'models.save': 'Modell speichern',
'models.save': 'Speichern',
'models.errName': 'Bitte benennen Sie das Modell',
'models.errUrl': 'Ungültige URL — Protokoll und Pfad nötig, z. B. https://api.deepseek.com/v1',
'models.errModel': 'Bitte den Modellnamen des Anbieters eingeben',
'models.errModel': 'Mindestens ein Modell hinzufügen (Modell-ID des Anbieters)',
'models.errKey': 'Bitte den API-Schlüssel eingeben',
'models.failTimeout': 'Zeitüberschreitung ({sec}s, keine Antwort): der Server erreicht diese Adresse nicht — Adresse, Port und Firewall prüfen',
'models.failAuth': 'Authentifizierung fehlgeschlagen (401): API-Schlüssel prüfen; bei Aggregatoren die Modellfreischaltung bestätigen',
@@ -1236,13 +1236,36 @@ export const de: Dict = {
'models.unavailable': '(nicht verfügbar)',
'models.noticeStrong': 'Erreichbarkeit gilt vom Workbench-Server aus:',
'models.noticeBody': 'eine im Browser öffnungsfähige Adresse ist nicht automatisch vom Server erreichbar; Verbindungs- und Dialoganfragen stellt der Server. API-Schlüssel liegen nur auf dem Server und werden nie angezeigt.',
'models.saved': 'Modell gespeichert',
'models.saved': 'Anbieter gespeichert',
'models.deleted': 'Modell gelöscht',
'models.assignSaved': 'Agentenmodelle aktualisiert',
'models.saveFailed': 'Speichern fehlgeschlagen: {detail}',
'models.loading': 'Lädt…',
'models.loadFailed': 'Einstellungen konnten nicht geladen werden',
'models.idleFormHint': '„Bearbeiten“ auf einer Karte wählen oder oben „Modell hinzufügen“ klicken',
'models.modelCount': '{n} Modelle',
'models.keyMeta': 'Schlüssel {tail}',
'models.keyNone': 'kein Schlüssel',
'models.testShort': 'Testen',
'models.addModel': 'Modell hinzufügen',
'models.fetchModels': 'Liste abrufen',
'models.fetching': 'Wird abgerufen…',
'models.fetchCount': '{n} Modelle · {k} gewählt',
'models.fetchFilterPh': 'Modelle filtern',
'models.fetchEmpty': 'Der Anbieter hat keine Modelle geliefert — Modell-IDs unten eingeben',
'models.fetchNoMatch': 'Keine passenden Modelle',
'models.fetchFail': '✕ Modellliste nicht abrufbar · {reason}',
'models.fetchUnsupported': 'Dieser Anbieter bietet keine Modellliste — Modell-IDs unten eingeben',
'models.fetchFormat': 'Die Antwort ist keine Modellliste — Modell-IDs unten eingeben',
'models.manualPh': 'Modell-ID eingeben, Enter zum Hinzufügen',
'models.manualAdd': 'Hinzufügen',
'models.removeModel': '{model} entfernen',
'models.noModels': 'Noch keine Modelle',
'models.delProviderTitle': 'Anbieter löschen',
'models.delProviderUsed': 'Modelle dieses Anbieters werden von {n} Agenten genutzt; sie wechseln zum Agent-Standard. Löschen?',
'models.delProviderFree': 'Diesen Anbieter mit {k} Modellen löschen? Nicht umkehrbar.',
'models.delProviderDefaultHint': 'Dieser Anbieter enthält das Standardmodell — zuerst einen anderen Standard wählen',
'models.delLastModelHint': 'Ein Anbieter behält mindestens ein Modell; zum Entfernen den Anbieter löschen',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Film-Atelier',
+36 -13
View File
@@ -1164,12 +1164,12 @@ export const en = {
'models.title': 'Models',
'models.subtitle': 'Add custom LLMs for your agents · phase 1 covers LLMs only; image / video models come later',
'models.statusCount': '{n} models · {m} available',
'models.add': 'Add model',
'models.add': 'Add provider',
'models.tabLlm': 'Language models',
'models.tabImage': 'Image models',
'models.tabVideo': 'Video models',
'models.comingSoon': 'Coming later',
'models.empty': 'No models yet — click "Add model" in the top right to connect your first LLM',
'models.empty': 'No providers yet — click "Add provider" in the top right to connect your first LLM service',
'models.typeLocal': 'Local',
'models.typeLan': 'LAN',
'models.typeCloud': 'Cloud',
@@ -1188,11 +1188,11 @@ export const en = {
'models.lastTestAt': 'last test {time}',
'models.consecutiveFails': '{n} consecutive failures',
'models.cloudPaused': 'Cloud calls paused',
'models.formTitleAdd': 'Add model',
'models.formTitleEdit': 'Edit model',
'models.formTitleAdd': 'Add provider',
'models.formTitleEdit': 'Edit provider',
'models.formClose': 'Close',
'models.fName': 'Name',
'models.fNamePh': 'Name this model, e.g. DeepSeek official',
'models.fName': 'Provider name',
'models.fNamePh': 'e.g. DeepSeek official, local Ollama',
'models.fApiType': 'API type',
'models.apiOpenai': 'OpenAI-compatible',
'models.apiAnthropic': 'Anthropic',
@@ -1200,8 +1200,8 @@ export const en = {
'models.apiTypeHelp': 'Phase 1 supports OpenAI-compatible APIs only; other protocols arrive with image / video models',
'models.fBaseUrl': 'Base URL',
'models.baseUrlHelp': 'Include protocol and port, e.g. http://192.168.200.18:11434/v1',
'models.fModel': 'Model name',
'models.modelHelp': 'Use the model ID from your provider',
'models.fModel': 'Models',
'models.modelHelp': 'Fetch the provider\'s model list and tick models, or type model IDs; the connection test uses the first model',
'models.fApiKey': 'API key',
'models.keyNoEcho': 'never echoed',
'models.keyHelp': 'Stored on this server only; the full key is never shown in the UI',
@@ -1212,10 +1212,10 @@ export const en = {
'models.testFail': '✕ Test failed · {reason}',
'models.needTest': 'Save unlocks after a passing test',
'models.testBtn': 'Test connection',
'models.save': 'Save model',
'models.errName': 'Please name this model',
'models.save': 'Save',
'models.errName': 'Please name this provider',
'models.errUrl': 'Invalid URL — it needs protocol and path, e.g. https://api.deepseek.com/v1',
'models.errModel': 'Please enter the model name from your provider',
'models.errModel': 'Add at least one model, using the model ID from your provider',
'models.errKey': 'Please enter the API key',
'models.failTimeout': 'Timed out ({sec}s, no response): the server cannot reach this address — check the address, port and firewall',
'models.failAuth': 'Authentication failed (401): check that the API key is correct and not expired; with aggregators, confirm the model is enabled for your account',
@@ -1238,13 +1238,36 @@ export const en = {
'models.unavailable': '(unavailable)',
'models.noticeStrong': 'Connectivity is measured from the workbench server:',
'models.noticeBody': 'a URL opening in your browser does not mean the server running this workbench can reach it; connection tests and chat requests are made by the server. API keys are stored on the server only and never echoed in the UI.',
'models.saved': 'Model saved',
'models.saved': 'Provider saved',
'models.deleted': 'Model deleted',
'models.assignSaved': 'Agent models updated',
'models.saveFailed': 'Save failed: {detail}',
'models.loading': 'Loading…',
'models.loadFailed': 'Failed to load settings',
'models.idleFormHint': 'Pick "Edit" on a card, or click "Add model" above',
'models.idleFormHint': 'Pick "Edit" on a card, or click "Add provider" above',
'models.modelCount': '{n} models',
'models.keyMeta': 'key {tail}',
'models.keyNone': 'no key set',
'models.testShort': 'Test',
'models.addModel': 'Add model',
'models.fetchModels': 'Fetch list',
'models.fetching': 'Fetching…',
'models.fetchCount': '{n} models · {k} selected',
'models.fetchFilterPh': 'Filter models',
'models.fetchEmpty': 'The provider returned no models — type model IDs below',
'models.fetchNoMatch': 'No matching models',
'models.fetchFail': '✕ Could not fetch the model list · {reason}',
'models.fetchUnsupported': 'This provider does not offer a model list — type model IDs below',
'models.fetchFormat': 'The provider response is not a model list — type model IDs below',
'models.manualPh': 'Type a model ID, Enter to add',
'models.manualAdd': 'Add',
'models.removeModel': 'Remove {model}',
'models.noModels': 'No models added yet',
'models.delProviderTitle': 'Delete provider',
'models.delProviderUsed': 'Models of this provider are used by {n} agents; they will fall back to the agent default. Delete?',
'models.delProviderFree': 'Delete this provider and its {k} models? This cannot be undone.',
'models.delProviderDefaultHint': 'This provider holds the agent default model — pick another default first',
'models.delLastModelHint': 'A provider keeps at least one model; delete the provider to remove it',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Film Atelier',
+31 -8
View File
@@ -1162,7 +1162,7 @@ export const es: Dict = {
'models.title': 'Modelos',
'models.subtitle': 'Añade LLM personalizados para tus agentes · la fase 1 solo incluye LLM; los modelos de imagen / vídeo llegarán después',
'models.statusCount': '{n} modelos · {m} disponibles',
'models.add': 'Añadir modelo',
'models.add': 'Añadir proveedor',
'models.tabLlm': 'Modelos de lenguaje',
'models.tabImage': 'Modelos de imagen',
'models.tabVideo': 'Modelos de vídeo',
@@ -1186,10 +1186,10 @@ export const es: Dict = {
'models.lastTestAt': 'última prueba {time}',
'models.consecutiveFails': '{n} fallos consecutivos',
'models.cloudPaused': 'Llamadas en la nube en pausa',
'models.formTitleAdd': 'Añadir modelo',
'models.formTitleEdit': 'Editar modelo',
'models.formTitleAdd': 'Añadir proveedor',
'models.formTitleEdit': 'Editar proveedor',
'models.formClose': 'Cerrar',
'models.fName': 'Nombre',
'models.fName': 'Nombre del proveedor',
'models.fNamePh': 'Nombra el modelo, p. ej. DeepSeek oficial',
'models.fApiType': 'Tipo de API',
'models.apiOpenai': 'Compatible con OpenAI',
@@ -1198,7 +1198,7 @@ export const es: Dict = {
'models.apiTypeHelp': 'La fase 1 solo admite APIs compatibles con OpenAI; otros protocolos llegarán con los modelos de imagen / vídeo',
'models.fBaseUrl': 'URL base',
'models.baseUrlHelp': 'Incluye protocolo y puerto, p. ej. http://192.168.200.18:11434/v1',
'models.fModel': 'Nombre del modelo',
'models.fModel': 'Modelos',
'models.modelHelp': 'Usa el ID de modelo que indica tu proveedor',
'models.fApiKey': 'Clave API',
'models.keyNoEcho': 'nunca se muestra',
@@ -1210,10 +1210,10 @@ export const es: Dict = {
'models.testFail': '✕ Prueba fallida · {reason}',
'models.needTest': 'Guardar se habilita tras superar la prueba',
'models.testBtn': 'Test connection',
'models.save': 'Guardar modelo',
'models.save': 'Guardar',
'models.errName': 'Ponle nombre al modelo',
'models.errUrl': 'URL no válida: debe incluir protocolo y ruta, p. ej. https://api.deepseek.com/v1',
'models.errModel': 'Introduce el nombre del modelo de tu proveedor',
'models.errModel': 'Añade al menos un modelo (ID del proveedor)',
'models.errKey': 'Introduce la clave API',
'models.failTimeout': 'Tiempo de espera agotado ({sec}s sin respuesta): el servidor no llega a esta dirección; revisa dirección, puerto y cortafuegos',
'models.failAuth': 'Autenticación fallida (401): comprueba que la clave API sea correcta y no haya caducado; con agregadores, confirma que el modelo esté activado',
@@ -1236,13 +1236,36 @@ export const es: Dict = {
'models.unavailable': '(no disponible)',
'models.noticeStrong': 'La conectividad se mide desde el servidor del workbench:',
'models.noticeBody': 'que una URL abra en tu navegador no significa que el servidor pueda acceder a ella; las pruebas de conexión y las peticiones las hace el servidor. Las claves API se guardan solo en el servidor y nunca se muestran.',
'models.saved': 'Modelo guardado',
'models.saved': 'Proveedor guardado',
'models.deleted': 'Modelo eliminado',
'models.assignSaved': 'Modelos de agentes actualizados',
'models.saveFailed': 'Error al guardar: {detail}',
'models.loading': 'Cargando…',
'models.loadFailed': 'No se pudieron cargar los ajustes',
'models.idleFormHint': 'Pulsa «Editar» en una tarjeta o «Añadir modelo» arriba',
'models.modelCount': '{n} modelos',
'models.keyMeta': 'clave {tail}',
'models.keyNone': 'sin clave',
'models.testShort': 'Probar',
'models.addModel': 'Añadir modelo',
'models.fetchModels': 'Obtener lista',
'models.fetching': 'Obteniendo…',
'models.fetchCount': '{n} modelos · {k} elegidos',
'models.fetchFilterPh': 'Filtrar modelos',
'models.fetchEmpty': 'El proveedor no devolvió modelos — escribe los ID abajo',
'models.fetchNoMatch': 'Sin coincidencias',
'models.fetchFail': '✕ No se pudo obtener la lista · {reason}',
'models.fetchUnsupported': 'Este proveedor no ofrece lista de modelos — escribe los ID abajo',
'models.fetchFormat': 'La respuesta no es una lista de modelos — escribe los ID abajo',
'models.manualPh': 'Escribe un ID de modelo, Enter para añadir',
'models.manualAdd': 'Añadir',
'models.removeModel': 'Quitar {model}',
'models.noModels': 'Aún no hay modelos',
'models.delProviderTitle': 'Eliminar proveedor',
'models.delProviderUsed': '{n} agentes usan modelos de este proveedor; pasarán al predeterminado. ¿Eliminar?',
'models.delProviderFree': '¿Eliminar este proveedor y sus {k} modelos? No se puede deshacer.',
'models.delProviderDefaultHint': 'Este proveedor contiene el modelo predeterminado — elige otro antes',
'models.delLastModelHint': 'Un proveedor conserva al menos un modelo; elimina el proveedor para quitarlo',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Atelier de Cine',
+31 -8
View File
@@ -1162,7 +1162,7 @@ export const fr: Dict = {
'models.title': 'Modèles',
'models.subtitle': 'Ajoutez des LLM personnalisés pour vos agents · la phase 1 ne couvre que les LLM ; les modèles image / vidéo arriveront ensuite',
'models.statusCount': '{n} modèles · {m} disponibles',
'models.add': 'Ajouter un modèle',
'models.add': 'Ajouter un fournisseur',
'models.tabLlm': 'Modèles de langage',
'models.tabImage': "Modèles d'image",
'models.tabVideo': 'Modèles vidéo',
@@ -1186,10 +1186,10 @@ export const fr: Dict = {
'models.lastTestAt': 'dernier test {time}',
'models.consecutiveFails': '{n} échecs consécutifs',
'models.cloudPaused': 'Appels cloud suspendus',
'models.formTitleAdd': 'Ajouter un modèle',
'models.formTitleEdit': 'Modifier le modèle',
'models.formTitleAdd': 'Ajouter un fournisseur',
'models.formTitleEdit': 'Modifier le fournisseur',
'models.formClose': 'Fermer',
'models.fName': 'Nom',
'models.fName': 'Nom du fournisseur',
'models.fNamePh': 'Nommez le modèle, ex. DeepSeek officiel',
'models.fApiType': 'Type d’API',
'models.apiOpenai': 'Compatible OpenAI',
@@ -1198,7 +1198,7 @@ export const fr: Dict = {
'models.apiTypeHelp': 'La phase 1 ne prend en charge que les API compatibles OpenAI ; les autres protocoles arriveront avec les modèles image / vidéo',
'models.fBaseUrl': 'URL de base',
'models.baseUrlHelp': 'Avec protocole et port, ex. http://192.168.200.18:11434/v1',
'models.fModel': 'Nom du modèle',
'models.fModel': 'Modèles',
'models.modelHelp': 'Utilisez l’identifiant de modèle fourni par votre prestataire',
'models.fApiKey': 'Clé API',
'models.keyNoEcho': 'jamais affichée',
@@ -1210,10 +1210,10 @@ export const fr: Dict = {
'models.testFail': '✕ Échec du test · {reason}',
'models.needTest': 'Enregistrement possible après un test réussi',
'models.testBtn': 'Test connection',
'models.save': 'Enregistrer le modèle',
'models.save': 'Enregistrer',
'models.errName': 'Veuillez nommer le modèle',
'models.errUrl': 'URL invalide — protocole et chemin requis, ex. https://api.deepseek.com/v1',
'models.errModel': 'Saisissez le nom du modèle fourni par votre prestataire',
'models.errModel': 'Ajoutez au moins un modèle (ID du fournisseur)',
'models.errKey': 'Saisissez la clé API',
'models.failTimeout': 'Délai dépassé ({sec}s sans réponse) : le serveur n’atteint pas cette adresse — vérifiez adresse, port et pare-feu',
'models.failAuth': 'Échec d’authentification (401) : vérifiez que la clé API est correcte et non expirée ; avec les agrégateurs, confirmez que le modèle est activé',
@@ -1236,13 +1236,36 @@ export const fr: Dict = {
'models.unavailable': '(indisponible)',
'models.noticeStrong': 'La connectivité est mesurée depuis le serveur du workbench :',
'models.noticeBody': 'une URL qui s’ouvre dans votre navigateur n’est pas forcément accessible au serveur ; les tests de connexion et les requêtes sont faits par le serveur. Les clés API restent sur le serveur et ne sont jamais affichées.',
'models.saved': 'Modèle enregistré',
'models.saved': 'Fournisseur enregistré',
'models.deleted': 'Modèle supprimé',
'models.assignSaved': 'Modèles des agents mis à jour',
'models.saveFailed': 'Échec de l’enregistrement : {detail}',
'models.loading': 'Chargement…',
'models.loadFailed': 'Impossible de charger les paramètres',
'models.idleFormHint': 'Cliquez « Modifier » sur une carte ou « Ajouter un modèle » ci-dessus',
'models.modelCount': '{n} modèles',
'models.keyMeta': 'clé {tail}',
'models.keyNone': 'aucune clé',
'models.testShort': 'Tester',
'models.addModel': 'Ajouter un modèle',
'models.fetchModels': 'Récupérer la liste',
'models.fetching': 'Récupération…',
'models.fetchCount': '{n} modèles · {k} choisis',
'models.fetchFilterPh': 'Filtrer les modèles',
'models.fetchEmpty': 'Le fournisseur n’a renvoyé aucun modèle — saisissez les ID ci-dessous',
'models.fetchNoMatch': 'Aucun modèle correspondant',
'models.fetchFail': '✕ Liste des modèles indisponible · {reason}',
'models.fetchUnsupported': 'Ce fournisseur ne propose pas de liste — saisissez les ID ci-dessous',
'models.fetchFormat': 'La réponse n’est pas une liste de modèles — saisissez les ID ci-dessous',
'models.manualPh': 'Saisir un ID de modèle, Entrée pour ajouter',
'models.manualAdd': 'Ajouter',
'models.removeModel': 'Retirer {model}',
'models.noModels': 'Aucun modèle ajouté',
'models.delProviderTitle': 'Supprimer le fournisseur',
'models.delProviderUsed': '{n} agents utilisent des modèles de ce fournisseur ; ils passeront au modèle par défaut. Supprimer ?',
'models.delProviderFree': 'Supprimer ce fournisseur et ses {k} modèles ? Irréversible.',
'models.delProviderDefaultHint': 'Ce fournisseur contient le modèle par défaut — choisissez-en un autre d’abord',
'models.delLastModelHint': 'Un fournisseur garde au moins un modèle ; supprimez le fournisseur pour le retirer',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': 'Atelier Cinéma',
+31 -8
View File
@@ -1162,7 +1162,7 @@ export const ja: Dict = {
'models.title': 'モデル設定',
'models.subtitle': 'エージェントが使うカスタム LLM を追加 · 第一段階は LLM のみ、画像 / 動画モデルは後日',
'models.statusCount': 'モデル {n} 件 · 利用可能 {m} 件',
'models.add': 'モデルを追加',
'models.add': 'プロバイダーを追加',
'models.tabLlm': '大規模言語モデル',
'models.tabImage': '画像モデル',
'models.tabVideo': '動画モデル',
@@ -1186,10 +1186,10 @@ export const ja: Dict = {
'models.lastTestAt': '前回テスト {time}',
'models.consecutiveFails': '連続失敗 {n} 回',
'models.cloudPaused': 'クラウド呼び出しを一時停止',
'models.formTitleAdd': 'モデルを追加',
'models.formTitleEdit': 'モデルを編集',
'models.formTitleAdd': 'プロバイダーを追加',
'models.formTitleEdit': 'プロバイダーを編集',
'models.formClose': '閉じる',
'models.fName': '名前',
'models.fName': 'プロバイダー名',
'models.fNamePh': 'モデルの名前(例:DeepSeek 公式)',
'models.fApiType': 'API タイプ',
'models.apiOpenai': 'OpenAI 互換',
@@ -1198,7 +1198,7 @@ export const ja: Dict = {
'models.apiTypeHelp': '第一段階は OpenAI 互換 API のみ対応。他のプロトコルは画像 / 動画モデルと同時に提供予定',
'models.fBaseUrl': 'API アドレス',
'models.baseUrlHelp': 'プロトコルとポートを含めてください(例:http://192.168.200.18:11434/v1)',
'models.fModel': 'モデル名',
'models.fModel': 'モデル',
'models.modelHelp': 'プロバイダ提供のモデル ID を指定してください',
'models.fApiKey': 'API キー',
'models.keyNoEcho': '表示されません',
@@ -1210,10 +1210,10 @@ export const ja: Dict = {
'models.testFail': '✕ テスト失敗 · {reason}',
'models.needTest': 'テスト成功後に保存できます',
'models.testBtn': '接続テスト',
'models.save': 'モデルを保存',
'models.save': '保存',
'models.errName': 'モデルに名前を付けてください',
'models.errUrl': 'アドレス形式が不正です。プロトコルとパスを含めてください(例:https://api.deepseek.com/v1)',
'models.errModel': 'プロバイダ提供のモデル名を入力してください',
'models.errModel': 'モデルを 1 つ以上追加してください(プロバイダーのモデル ID)',
'models.errKey': 'API キーを入力してください',
'models.failTimeout': 'タイムアウト({sec} 秒無応答):サーバーからこのアドレスに届きません。アドレス・ポート・ファイアウォールを確認してください',
'models.failAuth': '認証失敗(401):API キーが正しいか・期限切れでないか確認してください。集約サービスの場合はモデル権限もご確認ください',
@@ -1236,13 +1236,36 @@ export const ja: Dict = {
'models.unavailable': '(利用不可)',
'models.noticeStrong': '接続性はワークベンチのサーバー基準です:',
'models.noticeBody': 'ブラウザで開けるアドレスでも、ワークベンチのサーバーからアクセスできるとは限りません。接続テストと対話リクエストはサーバーが行います。API キーはサーバーにのみ保存され、画面に表示されることはありません。',
'models.saved': 'モデルを保存しました',
'models.saved': 'プロバイダーを保存しました',
'models.deleted': 'モデルを削除しました',
'models.assignSaved': 'エージェントのモデルを更新しました',
'models.saveFailed': '保存に失敗しました:{detail}',
'models.loading': '読み込み中…',
'models.loadFailed': '設定の読み込みに失敗しました',
'models.idleFormHint': 'カードの「編集」を選ぶか、上の「モデルを追加」をクリックしてください',
'models.modelCount': '{n} 個のモデル',
'models.keyMeta': 'キー {tail}',
'models.keyNone': 'キー未設定',
'models.testShort': 'テスト',
'models.addModel': 'モデルを追加',
'models.fetchModels': '一覧を取得',
'models.fetching': '取得中…',
'models.fetchCount': '{n} 個 · {k} 個選択',
'models.fetchFilterPh': 'モデルを絞り込み',
'models.fetchEmpty': 'モデルが返されませんでした。下でモデル ID を入力してください',
'models.fetchNoMatch': '一致するモデルはありません',
'models.fetchFail': '✕ モデル一覧を取得できません · {reason}',
'models.fetchUnsupported': 'このプロバイダーはモデル一覧に対応していません。下でモデル ID を入力してください',
'models.fetchFormat': '応答がモデル一覧ではありません。下でモデル ID を入力してください',
'models.manualPh': 'モデル ID を入力して Enter で追加',
'models.manualAdd': '追加',
'models.removeModel': '{model} を削除',
'models.noModels': 'モデルはまだありません',
'models.delProviderTitle': 'プロバイダーを削除',
'models.delProviderUsed': 'このプロバイダーのモデルを {n} 個のエージェントが使用中です。削除後は既定モデルに切り替わります。削除しますか?',
'models.delProviderFree': 'このプロバイダーと {k} 個のモデルを削除しますか?元に戻せません。',
'models.delProviderDefaultHint': '既定モデルを含むため削除できません。先に別の既定モデルを指定してください',
'models.delLastModelHint': 'プロバイダーには最低 1 つのモデルが必要です。削除するにはプロバイダーごと削除してください',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '映画工房',
+31 -8
View File
@@ -1162,7 +1162,7 @@ export const ko: Dict = {
'models.title': '모델 설정',
'models.subtitle': '에이전트가 사용할 사용자 지정 LLM을 추가합니다 · 1단계는 LLM만 지원, 이미지 / 동영상 모델은 추후 제공',
'models.statusCount': '모델 {n}개 · 사용 가능 {m}개',
'models.add': '모델 추가',
'models.add': '공급자 추가',
'models.tabLlm': '대규모 언어 모델',
'models.tabImage': '이미지 모델',
'models.tabVideo': '동영상 모델',
@@ -1186,10 +1186,10 @@ export const ko: Dict = {
'models.lastTestAt': '마지막 테스트 {time}',
'models.consecutiveFails': '{n}회 연속 실패',
'models.cloudPaused': '클라우드 호출 일시 중지',
'models.formTitleAdd': '모델 추가',
'models.formTitleEdit': '모델 편집',
'models.formTitleAdd': '공급자 추가',
'models.formTitleEdit': '공급자 편집',
'models.formClose': '닫기',
'models.fName': '이름',
'models.fName': '공급자 이름',
'models.fNamePh': '모델 이름, 예: DeepSeek 공식',
'models.fApiType': 'API 유형',
'models.apiOpenai': 'OpenAI 호환',
@@ -1198,7 +1198,7 @@ export const ko: Dict = {
'models.apiTypeHelp': '1단계는 OpenAI 호환 API만 지원하며, 다른 프로토콜은 이미지 / 동영상 모델과 함께 제공됩니다',
'models.fBaseUrl': 'API 주소',
'models.baseUrlHelp': '프로토콜과 포트를 포함하세요, 예: http://192.168.200.18:11434/v1',
'models.fModel': '모델 이름',
'models.fModel': '모델',
'models.modelHelp': '공급자가 제공하는 모델 ID를 사용하세요',
'models.fApiKey': 'API 키',
'models.keyNoEcho': '표시되지 않음',
@@ -1210,10 +1210,10 @@ export const ko: Dict = {
'models.testFail': '✕ 테스트 실패 · {reason}',
'models.needTest': '테스트 통과 후 저장할 수 있습니다',
'models.testBtn': '연결 테스트',
'models.save': '모델 저장',
'models.save': '저장',
'models.errName': '모델 이름을 입력하세요',
'models.errUrl': '주소 형식이 올바르지 않습니다. 프로토콜과 경로를 포함하세요, 예: https://api.deepseek.com/v1',
'models.errModel': '공급자가 제공하는 모델 이름을 입력하세요',
'models.errModel': '모델을 하나 이상 추가하세요 (공급자 모델 ID)',
'models.errKey': 'API 키를 입력하세요',
'models.failTimeout': '시간 초과({sec}초 무응답): 서버가 이 주소에 접근할 수 없습니다. 주소·포트·방화벽을 확인하세요',
'models.failAuth': '인증 실패(401): API 키가 올바르고 만료되지 않았는지 확인하세요. 통합 서비스라면 모델 권한도 확인하세요',
@@ -1236,13 +1236,36 @@ export const ko: Dict = {
'models.unavailable': '(사용 불가)',
'models.noticeStrong': '연결성은 워크벤치 서버 기준입니다:',
'models.noticeBody': '브라우저에서 열리는 주소라도 워크벤치 서버가 접근 가능한 것은 아닙니다. 연결 테스트와 대화 요청은 서버가 수행합니다. API 키는 서버에만 저장되며 화면에 표시되지 않습니다.',
'models.saved': '모델을 저장했습니다',
'models.saved': '공급자를 저장했습니다',
'models.deleted': '모델을 삭제했습니다',
'models.assignSaved': '에이전트 모델을 업데이트했습니다',
'models.saveFailed': '저장 실패: {detail}',
'models.loading': '불러오는 중…',
'models.loadFailed': '설정을 불러오지 못했습니다',
'models.idleFormHint': '카드의 «편집»을 누르거나 위의 «모델 추가»를 클릭하세요',
'models.modelCount': '모델 {n}개',
'models.keyMeta': '키 {tail}',
'models.keyNone': '키 없음',
'models.testShort': '테스트',
'models.addModel': '모델 추가',
'models.fetchModels': '목록 가져오기',
'models.fetching': '가져오는 중…',
'models.fetchCount': '모델 {n}개 · {k}개 선택',
'models.fetchFilterPh': '모델 필터',
'models.fetchEmpty': '공급자가 모델을 반환하지 않았습니다. 아래에 모델 ID를 입력하세요',
'models.fetchNoMatch': '일치하는 모델 없음',
'models.fetchFail': '✕ 모델 목록을 가져오지 못했습니다 · {reason}',
'models.fetchUnsupported': '이 공급자는 모델 목록을 지원하지 않습니다. 아래에 모델 ID를 입력하세요',
'models.fetchFormat': '응답이 모델 목록이 아닙니다. 아래에 모델 ID를 입력하세요',
'models.manualPh': '모델 ID 입력 후 Enter로 추가',
'models.manualAdd': '추가',
'models.removeModel': '{model} 제거',
'models.noModels': '아직 모델이 없습니다',
'models.delProviderTitle': '공급자 삭제',
'models.delProviderUsed': '이 공급자의 모델을 에이전트 {n}개가 사용 중입니다. 삭제하면 기본 모델로 전환됩니다. 삭제할까요?',
'models.delProviderFree': '이 공급자와 모델 {k}개를 삭제할까요? 되돌릴 수 없습니다.',
'models.delProviderDefaultHint': '기본 모델이 포함되어 있습니다. 먼저 다른 기본 모델을 지정하세요',
'models.delLastModelHint': '공급자에는 모델이 하나 이상 필요합니다. 제거하려면 공급자를 삭제하세요',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '영화 공작소',
+36 -13
View File
@@ -1164,12 +1164,12 @@ export const zh: Dict = {
'models.title': '模型设置',
'models.subtitle': '添加自定义大语言模型(LLM),供智能体选用 · 第一阶段仅开放 LLM,图片 / 视频模型后续接入',
'models.statusCount': '{n} 个模型 · {m} 个可用',
'models.add': '添加模型',
'models.add': '添加服务商',
'models.tabLlm': '大语言模型',
'models.tabImage': '图片模型',
'models.tabVideo': '视频模型',
'models.comingSoon': '后续开放',
'models.empty': '还没有配置模型,点右上角「添加模型」接入第一个大语言模型',
'models.empty': '还没有配置服务商,点右上角「添加服务商」接入第一个大语言模型服务',
'models.typeLocal': '本机',
'models.typeLan': '局域网',
'models.typeCloud': '云端',
@@ -1188,11 +1188,11 @@ export const zh: Dict = {
'models.lastTestAt': '上次测试 {time}',
'models.consecutiveFails': '已连续失败 {n} 次',
'models.cloudPaused': '云端调用已暂停',
'models.formTitleAdd': '添加模型',
'models.formTitleEdit': '编辑模型',
'models.formTitleAdd': '添加服务商',
'models.formTitleEdit': '编辑服务商',
'models.formClose': '关闭',
'models.fName': '名称',
'models.fNamePh': '给模型起个名字,如 DeepSeek 官方',
'models.fName': '服务商名称',
'models.fNamePh': '如 DeepSeek 官方、本机 Ollama',
'models.fApiType': '接口类型',
'models.apiOpenai': 'OpenAI 兼容',
'models.apiAnthropic': 'Anthropic',
@@ -1200,8 +1200,8 @@ export const zh: Dict = {
'models.apiTypeHelp': '第一阶段仅支持 OpenAI 兼容接口,其余协议随图片 / 视频模型一同开放',
'models.fBaseUrl': '接口地址',
'models.baseUrlHelp': '需含协议与端口,如 http://192.168.200.18:11434/v1',
'models.fModel': '模型名称',
'models.modelHelp': '以服务商提供的模型 ID 为准',
'models.fModel': '模型',
'models.modelHelp': '可自动获取服务商的模型列表勾选,也可手动输入模型 ID;测试连接使用列表中的第一个模型',
'models.fApiKey': 'API 密钥',
'models.keyNoEcho': '密钥不回显',
'models.keyHelp': '仅保存在本机服务器,页面永不显示完整密钥',
@@ -1212,10 +1212,10 @@ export const zh: Dict = {
'models.testFail': '✕ 测试失败 · {reason}',
'models.needTest': '未测试通过前无法保存',
'models.testBtn': '测试连接',
'models.save': '保存模型',
'models.errName': '请给模型起个名字',
'models.save': '保存',
'models.errName': '请给服务商起个名字',
'models.errUrl': '地址格式不正确,需含协议与路径,示例:https://api.deepseek.com/v1',
'models.errModel': '请填写模型名称,以服务商提供的模型 ID 为准',
'models.errModel': '请至少添加一个模型,以服务商提供的模型 ID 为准',
'models.errKey': '请填写 API 密钥',
'models.failTimeout': '连接超时({sec} 秒无响应):服务器无法访问该地址,请检查地址、端口与防火墙',
'models.failAuth': '鉴权失败(401):请检查 API 密钥是否正确或已过期;使用聚合服务时,请确认账号已开通该模型权限',
@@ -1238,13 +1238,36 @@ export const zh: Dict = {
'models.unavailable': '(不可用)',
'models.noticeStrong': '连通性以工作台服务器为准:',
'models.noticeBody': '浏览器能打开某个地址,不代表运行工作台的服务器也能访问;连接测试与对话请求均由服务器发起。API 密钥仅保存在服务器本地,任何页面不回显。',
'models.saved': '模型已保存',
'models.saved': '服务商已保存',
'models.deleted': '模型已删除',
'models.assignSaved': '智能体模型已更新',
'models.saveFailed': '保存失败:{detail}',
'models.loading': '加载中…',
'models.loadFailed': '设置加载失败',
'models.idleFormHint': '从左侧选择「编辑」,或点上方「添加模型」新增配置',
'models.idleFormHint': '从左侧选择「编辑」,或点上方「添加服务商」新增配置',
'models.modelCount': '{n} 个模型',
'models.keyMeta': '密钥 {tail}',
'models.keyNone': '未设置密钥',
'models.testShort': '测试',
'models.addModel': '添加模型',
'models.fetchModels': '自动获取',
'models.fetching': '获取中…',
'models.fetchCount': '共 {n} 个模型 · 已选 {k} 个',
'models.fetchFilterPh': '筛选模型',
'models.fetchEmpty': '服务商没有返回任何模型,请在下方手动输入模型 ID 添加',
'models.fetchNoMatch': '没有匹配的模型',
'models.fetchFail': '✕ 获取模型列表失败 · {reason}',
'models.fetchUnsupported': '该服务商不支持自动获取模型列表,请在下方手动输入模型 ID 添加',
'models.fetchFormat': '服务商返回的内容不是模型列表,请在下方手动输入模型 ID 添加',
'models.manualPh': '手动输入模型 ID,回车添加',
'models.manualAdd': '添加',
'models.removeModel': '移除 {model}',
'models.noModels': '还没有添加模型',
'models.delProviderTitle': '删除服务商',
'models.delProviderUsed': '该服务商下的模型正被 {n} 个智能体使用,删除后这些智能体将改用「智能体默认」。确定删除?',
'models.delProviderFree': '确定删除该服务商及其下 {k} 个模型?删除后不可恢复。',
'models.delProviderDefaultHint': '该服务商包含「智能体默认」模型,请先指定其他默认模型',
'models.delLastModelHint': '服务商至少保留一个模型;如需移除请删除整个服务商',
// --- A shell + projects overview (HEL-608) ---
'shell.brand': '影工坊',
+6 -2
View File
@@ -721,8 +721,12 @@
onchange={(e) => setAgentModel(s, activeAgent.llm_role, e.currentTarget.value)}
>
<option value="">{t('agentsPage.modelDefault')}</option>
{#each s.llm_profiles as p (p.id)}
<option value={p.id}>{p.name} — {p.model}</option>
{#each s.llm_providers ?? [] as prov (prov.id)}
<optgroup label={prov.name}>
{#each prov.models as m (m.id)}
<option value={m.id}>{m.model}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
File diff suppressed because it is too large Load Diff
@@ -281,7 +281,8 @@
if (!agent || !s) return null;
const pid = s.agent_llm_assignments?.[agent.llm_role];
const profile = (s.llm_profiles ?? []).find((p) => p.id === pid);
return profile?.name ?? null;
if (!profile) return null;
return profile.provider_name ? `${profile.provider_name} · ${profile.model}` : profile.name;
});
function openAgentChat() {