Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4109723655 |
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '映画工房',
|
||||
|
||||
@@ -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': '영화 공작소',
|
||||
|
||||
@@ -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': '影工坊',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user