Merge PR #1264: integrate tested custom providers and Persian locale on current settings

This commit is contained in:
Haozhe Wu committed 2026-10-13 13:04:24 +08:00
commit f89b812741
35 files changed
+3839 -7

No files matched your search

+2
View File
@@ -175,3 +175,5 @@ bench_report.json
# Grounding ledger persisted by ad-hoc verification scripts into tests/
agent/tests/artifacts/
.idea/*
/custom-llm-providers.json
+14
View File
@@ -2424,3 +2424,17 @@ MIT License — see [LICENSE](LICENSE)
<p align="center">
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.Vibe-Trading&style=flat" alt="visitors"/>
</p>
### Custom model endpoints
Settings → Custom OpenAI-compatible providers tests a chat-completions endpoint,
saves its API key in the OS credential vault (`pip install "vibe-trading-ai[keyring]"`),
and activates it for new runs. Testing sends a short model request and may incur
the provider's normal API charge. Active profiles are immutable; save a changed
endpoint under a new ID and activate it after testing. Switch back through the
normal model provider selector. Keys are never stored in the profile JSON.
The same flow is available from `vibe-trading provider custom list`,
`vibe-trading provider custom add --id example --label Example --base-url https://api.example.com/v1 --model example-model`
(the key is prompted privately), and `vibe-trading provider custom activate example`.
The configured provider is used by CLI, API, Web, and agent runs alike.
+3
View File
@@ -215,6 +215,9 @@ from src.api.settings_routes import ( # noqa: F401, E402
_load_llm_providers,
)
from src.api.custom_provider_routes import register_custom_provider_routes # noqa: E402
register_custom_provider_routes(app, require_local_or_auth, require_settings_write_auth)
# --- Uploads ---
from src.api.uploads_routes import register_uploads_routes # noqa: E402
register_uploads_routes(app)
+5
View File
@@ -5433,6 +5433,8 @@ def _build_parser() -> argparse.ArgumentParser:
login_parser = provider_subparsers.add_parser("login", help="Authenticate with an OAuth provider")
login_parser.add_argument("provider", help="OAuth provider name, e.g. openai-codex")
provider_subparsers.add_parser("doctor", help="Print redacted provider diagnostics")
from cli.custom_providers import configure_parser as configure_custom_providers
configure_custom_providers(provider_subparsers.add_parser("custom", help="Manage tested custom model endpoints"))
# QVERIS-INTEGRATION
data_parser = subparsers.add_parser("data", help="Manage data routing mode") # QVERIS-INTEGRATION
@@ -6565,6 +6567,9 @@ def main(argv: list[str] | None = None) -> int:
return cmd_provider_login(args.provider)
if args.provider_command == "doctor":
return cmd_provider_doctor()
if args.provider_command == "custom":
from cli.custom_providers import run as run_custom_providers
return run_custom_providers(args)
console.print("[red]provider requires a subcommand.[/red] Try: vibe-trading provider doctor")
return EXIT_USAGE_ERROR
if args.command == "channels":
+46
View File
@@ -0,0 +1,46 @@
"""CLI entry for the same tested custom profiles used by Settings."""
from __future__ import annotations
import asyncio
import getpass
import json
def configure_parser(parser) -> None:
"""Add list, add (test and save), and activate commands."""
commands = parser.add_subparsers(dest="custom_command", required=True)
commands.add_parser("list", help="List redacted custom provider metadata")
add = commands.add_parser("add", help="Test and save a provider; prompts privately for its key")
for name in ("id", "label", "base-url", "model"):
add.add_argument(f"--{name}", required=True)
activate = commands.add_parser("activate", help="Select a saved provider for new runs")
activate.add_argument("id")
def run(args) -> int:
"""Run a custom provider operation without accepting a key on argv."""
from src.providers.custom_profiles import CustomProviderStore, STORE_LOCK
store = CustomProviderStore()
try:
if args.custom_command == "list":
result = store.list_public()
elif args.custom_command == "add":
from src.api.custom_provider_routes import _perform_completion_test, _validate_custom_base_url
base_url = _validate_custom_base_url(args.base_url)
key = getpass.getpass("API key (hidden): ")
asyncio.run(_perform_completion_test(base_url=base_url, model=args.model, api_key=key))
result = store.save(profile_id=args.id, label=args.label, base_url=base_url, model=args.model, api_key=key)
else:
# Canonical settings writes use the same host paths as the API.
import api_server # noqa: F401
from src.api.settings_routes import activate_custom_provider
with STORE_LOCK:
result = activate_custom_provider(store.get(args.id), store=store)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (RuntimeError, ValueError, OSError) as exc:
print(str(exc))
return 1
+216
View File
@@ -0,0 +1,216 @@
"""Web API for test-first custom OpenAI-compatible provider profiles."""
from __future__ import annotations
import hashlib
import ipaddress
import socket
import time
import uuid
from typing import Any, Awaitable, Callable
from urllib.parse import urlsplit
import httpx
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
from src.api.settings_routes import activate_custom_provider
from src.providers.custom_profiles import CustomProviderStore, STORE_LOCK
AuthDep = Callable[..., Awaitable[Any] | Any]
_TEST_TTL_S = 300.0
_PROVIDER_REQUEST_TIMEOUT_S = 60.0
_TESTS: dict[str, tuple[float, str]] = {}
def _build_store() -> CustomProviderStore:
return CustomProviderStore()
class CustomProviderTestRequest(BaseModel):
base_url: str = Field(min_length=1, max_length=500)
model: str = Field(min_length=1, max_length=200)
api_key: str = Field(min_length=1, max_length=500)
class CustomProviderSaveRequest(CustomProviderTestRequest):
id: str = Field(min_length=1, max_length=64)
label: str = Field(min_length=1, max_length=100)
test_id: str = Field(min_length=1, max_length=80)
class CustomProviderActivateRequest(BaseModel):
confirm: bool = False
def _validate_custom_base_url(value: str) -> str:
normalized = value.strip().rstrip("/")
parsed = urlsplit(normalized)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError("Provider base URL must be an HTTP(S) URL without embedded credentials")
hostname = parsed.hostname.lower()
if hostname in {"localhost", "localhost.localdomain", "metadata.google.internal"}:
raise ValueError("Provider base URL cannot target local or metadata services")
try:
address = ipaddress.ip_address(hostname)
except ValueError:
address = None
if address is not None and (
address.is_loopback
or address.is_private
or address.is_link_local
or address.is_reserved
or address.is_multicast
or address.is_unspecified
):
raise ValueError("Provider base URL cannot target local or private network addresses")
if normalized.endswith("/chat/completions"):
normalized = normalized[:-len("/chat/completions")]
return normalized
def _reject_private_dns_targets(base_url: str) -> None:
hostname = urlsplit(base_url).hostname
if not hostname:
raise ValueError("Provider base URL must include a hostname")
try:
results = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError("Provider hostname could not be resolved") from exc
for result in results:
address = ipaddress.ip_address(result[4][0])
if (
address.is_loopback
or address.is_private
or address.is_link_local
or address.is_reserved
or address.is_multicast
or address.is_unspecified
):
raise ValueError("Provider hostname resolves to a local or private network address")
def _completion_url(base_url: str) -> str:
url = _validate_custom_base_url(base_url)
if url.endswith("/chat/completions"):
return url
return f"{url}/chat/completions"
async def _perform_completion_test(*, base_url: str, model: str, api_key: str) -> dict[str, Any]:
started = time.perf_counter()
normalized_base_url = _validate_custom_base_url(base_url)
import asyncio
await asyncio.to_thread(_reject_private_dns_targets, normalized_base_url)
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
body = {
"model": model,
"messages": [{"role": "user", "content": "Reply with exactly: PROVIDER_TEST_OK"}],
"max_tokens": 32,
"temperature": 0,
}
try:
async with httpx.AsyncClient(timeout=_PROVIDER_REQUEST_TIMEOUT_S, follow_redirects=False) as client:
response = await client.post(_completion_url(normalized_base_url), headers=headers, json=body)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
raise ValueError(f"Provider returned HTTP {exc.response.status_code}") from exc
except (httpx.HTTPError, ValueError) as exc:
raise ValueError("Provider test failed: endpoint did not return valid JSON") from exc
try:
content = payload["choices"][0]["message"].get("content", "")
except (KeyError, IndexError, TypeError) as exc:
raise ValueError("Provider test failed: response had no chat completion") from exc
if not isinstance(content, str) or not content.strip():
raise ValueError("Provider test failed: response had no text")
return {
"response_preview": str(content).replace(api_key, "[redacted]").strip()[:500],
"latency_ms": round((time.perf_counter() - started) * 1000),
}
def _fingerprint(payload: CustomProviderTestRequest) -> str:
raw = "\x00".join((payload.base_url.strip(), payload.model.strip(), payload.api_key.strip()))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _prune_tests() -> None:
now = time.monotonic()
for test_id, (expires, _) in list(_TESTS.items()):
if expires <= now:
_TESTS.pop(test_id, None)
def _activate_profile(profile: dict[str, Any]) -> dict[str, Any]:
return activate_custom_provider(profile, store=_build_store())
def register_custom_provider_routes(
app: FastAPI,
require_local_or_auth: AuthDep,
require_settings_write_auth: AuthDep,
) -> None:
@app.get("/settings/custom-providers", dependencies=[Depends(require_local_or_auth)])
def list_custom_providers() -> dict[str, Any]:
return {"status": "ok", "providers": _build_store().list_public()}
@app.post("/settings/custom-providers/test", dependencies=[Depends(require_settings_write_auth)])
async def test_custom_provider(payload: CustomProviderTestRequest) -> dict[str, Any]:
try:
payload.base_url = _validate_custom_base_url(payload.base_url)
if not payload.model.strip() or not payload.api_key.strip():
raise ValueError("Model and API key must not be blank")
result = await _perform_completion_test(
base_url=payload.base_url, model=payload.model, api_key=payload.api_key
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_prune_tests()
test_id = uuid.uuid4().hex
_TESTS[test_id] = (time.monotonic() + _TEST_TTL_S, _fingerprint(payload))
return {"status": "ok", "test_id": test_id, **result}
@app.post("/settings/custom-providers", dependencies=[Depends(require_settings_write_auth)])
def save_custom_provider(payload: CustomProviderSaveRequest) -> dict[str, Any]:
with STORE_LOCK:
_prune_tests()
try:
payload.base_url = _validate_custom_base_url(payload.base_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
expected = _TESTS.get(payload.test_id)
if expected is None or expected[0] <= time.monotonic() or expected[1] != _fingerprint(payload):
raise HTTPException(status_code=409, detail="A successful provider test is required before saving")
try:
base_url = _validate_custom_base_url(payload.base_url)
profile = _build_store().save(
profile_id=payload.id,
label=payload.label,
base_url=base_url,
model=payload.model,
api_key=payload.api_key,
)
except (RuntimeError, ValueError, OSError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_TESTS.pop(payload.test_id, None)
return {"status": "ok", "provider": profile}
@app.post("/settings/custom-providers/{profile_id}/activate", dependencies=[Depends(require_settings_write_auth)])
def activate_custom_provider(profile_id: str, payload: CustomProviderActivateRequest) -> dict[str, Any]:
if not payload.confirm:
raise HTTPException(status_code=400, detail="Explicit confirmation is required to activate a provider")
try:
with STORE_LOCK:
profile = _build_store().get(profile_id)
return _activate_profile(profile)
except (RuntimeError, ValueError, OSError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
+49 -1
View File
@@ -436,6 +436,19 @@ def _build_llm_settings_response(
authenticated, auth_status = False, ""
api_key_configured = authenticated
api_key_hint = auth_status if authenticated else None
options = LLM_PROVIDERS
if provider_name == "custom":
from src.providers.custom_profiles import CustomProviderStore
active = next((p for p in CustomProviderStore().list_public() if p["active"]), None)
active_model = active["model"] if active else env_values.get("LANGCHAIN_MODEL_NAME", "")
active_url = active["base_url"] if active else ""
provider = LLMProviderOption(name="custom", label="Custom (credential vault)",
api_key_env=None, base_url_env="OPENAI_BASE_URL", default_model=active_model,
default_base_url=active_url, auth_type="credential_vault", reasoning_efforts=[])
env_values = {**env_values, "LANGCHAIN_MODEL_NAME": active_model, "OPENAI_BASE_URL": active_url}
api_key_configured, api_key_hint = bool(active and active["api_key_configured"]), None
options = [*LLM_PROVIDERS, provider]
return LLMSettingsResponse(
provider=provider.name,
model_name=env_values.get("LANGCHAIN_MODEL_NAME", provider.default_model),
@@ -450,10 +463,31 @@ def _build_llm_settings_response(
reasoning_effort=env_values.get("LANGCHAIN_REASONING_EFFORT", "").strip().lower(),
sse_timeout_seconds=host._coerce_int(env_values.get("VIBE_TRADING_SSE_TIMEOUT", "90"), 90),
env_path=host._project_relative_path(host.ENV_PATH),
providers=LLM_PROVIDERS,
providers=options,
)
def activate_custom_provider(profile: dict[str, Any], *, store: Any) -> dict[str, Any]:
"""Activate tested vault metadata through the canonical settings writer.
Secrets remain in the vault. The runtime resolves model, URL and key from
one active profile, so it never pairs another provider's ambient key with
the selected endpoint. The caller holds the shared store mutation lock.
"""
updates = {"LANGCHAIN_PROVIDER": "custom", "LANGCHAIN_MODEL_NAME": profile["model"]}
previous = store._read()
store.activate(profile["id"])
try:
_persist_settings_updates(updates)
except Exception:
store._write(previous)
raise
for key, value in updates.items():
os.environ[key] = str(value)
reset_env_config()
return {"status": "ok", "active_id": profile["id"], "model": profile["model"], "base_url": profile["base_url"]}
def _build_source_orders(env_values: Dict[str, str]) -> List[SourceOrderEntry]:
"""Build per-market source-order entries for the settings payload.
@@ -561,6 +595,9 @@ def _sync_runtime_env(provider: LLMProviderOption, updates: Dict[str, str]) -> N
else:
os.environ.pop(key, None)
if provider.auth_type == "credential_vault":
reset_env_config()
return
if provider.auth_type in {"gh_cli", "claude_cli"}:
# The Copilot SDK / Claude Code CLI own credential discovery and transport.
os.environ.pop("OPENAI_API_KEY", None)
@@ -674,6 +711,17 @@ def register_settings_routes(
host_ref = _host()
provider_name = payload.provider.strip().lower()
provider = LLM_PROVIDER_BY_NAME.get(provider_name)
if provider_name == "custom":
from src.providers.custom_profiles import active_provider_credentials
active = active_provider_credentials()
if (payload.model_name.strip() != active["model"]
or payload.base_url not in (None, active["base_url"])
or payload.api_key or payload.clear_api_key):
raise HTTPException(status_code=400, detail="Edit and test a custom provider in the provider manager before activating it")
provider = LLMProviderOption(name="custom", label="Custom (credential vault)", api_key_env=None,
base_url_env="OPENAI_BASE_URL", default_model=active["model"], default_base_url=active["base_url"],
auth_type="credential_vault", reasoning_efforts=[])
if provider is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported LLM provider"
+3
View File
@@ -168,6 +168,9 @@ class LLMConfig(_EnvBase):
# preflight could not tell a missing key from a working one and the failure
# surfaced as an opaque 401 at the first call instead (#1223).
anthropic_api_key: str = Field(alias="ANTHROPIC_API_KEY", default="")
langchain_max_tokens: int | None = Field(
alias="LANGCHAIN_MAX_TOKENS", default=None, gt=0
)
anthropic_max_tokens: int | None = Field(alias="ANTHROPIC_MAX_TOKENS", default=None, gt=0)
timeout_seconds: int = Field(alias="TIMEOUT_SECONDS", default=120)
max_retries: int = Field(alias="MAX_RETRIES", default=2)
+9
View File
@@ -315,6 +315,8 @@ def get_provider_capabilities(
the explicit provider choice always wins.
"""
normalized = (provider or "").strip().lower().replace("_", "-")
if normalized == "custom":
return ProviderCapabilities("custom", None, "OPENAI_BASE_URL", capture_reasoning=True)
if normalized == "openai-codex":
return _PROVIDERS["openai-codex"]
if normalized and normalized != "openai":
@@ -395,6 +397,13 @@ def get_llm_credentials(
preflight, environment synchronization, and runtime construction all
consume the same endpoint.
"""
normalized_provider = (provider or "").strip().lower().replace("_", "-")
if normalized_provider == "custom":
from src.providers.custom_profiles import active_provider_credentials
active = active_provider_credentials()
return {"provider": "custom", **active}
caps = get_provider_capabilities(provider, model)
key_env, base_env = caps.api_key_env, caps.base_url_env
+126
View File
@@ -0,0 +1,126 @@
"""Persistent metadata and OS-vault secrets for custom LLM providers."""
from __future__ import annotations
import json
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from threading import RLock
from src.config.accessor import get_env_config
from src.config.writer import _write_json_atomically
from src.config.paths import get_runtime_root
from src.trading.credentials import CredentialStore
CONFIG_FILENAME = "custom-llm-providers.json"
STORE_LOCK = RLock()
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
class CustomProviderStore:
"""Store custom-provider metadata on disk and API keys in the OS vault."""
def __init__(self, path: Path | None = None, backend: Any = None) -> None:
self.path = path or (get_runtime_root() / CONFIG_FILENAME)
self.credentials = CredentialStore(backend)
def list_public(self) -> list[dict[str, Any]]:
payload = self._read()
profiles = payload.get("profiles", {})
return [
{
"id": profile_id,
"label": str(item.get("label") or profile_id),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
"api_key_configured": self.credentials.status(
f"custom-provider:{profile_id}", ["api_key"]
).get("api_key", False),
"active": (get_env_config().llm.langchain_provider == "custom" and payload.get("active_id") == profile_id),
"last_tested_at": item.get("last_tested_at"),
}
for profile_id, item in sorted(profiles.items())
if isinstance(item, dict)
]
def save(
self,
*,
profile_id: str,
label: str,
base_url: str,
model: str,
api_key: str,
last_tested_at: str | None = None,
) -> dict[str, Any]:
"""Save tested metadata and vault credentials without changing an active profile."""
with STORE_LOCK:
profile_id = profile_id.strip().lower()
if not _ID_RE.fullmatch(profile_id):
raise ValueError("Provider id must use lowercase letters, numbers, dot, dash, or underscore")
if not label.strip() or len(label.strip()) > 100:
raise ValueError("Provider label must contain 1 to 100 characters")
if not base_url.strip() or not model.strip() or not api_key.strip():
raise ValueError("Provider base URL, model, and API key are required")
payload = self._read()
profiles = payload.setdefault("profiles", {})
if payload.get("active_id") == profile_id:
raise ValueError("Save changes under a new id before activating the updated profile")
profiles[profile_id] = {
"label": label.strip(),
"base_url": base_url.strip().rstrip("/"),
"model": model.strip(),
"last_tested_at": last_tested_at or _now(),
}
self.credentials.save(f"custom-provider:{profile_id}", {"api_key": api_key.strip()})
self._write(payload)
return next(item for item in self.list_public() if item["id"] == profile_id)
def get(self, profile_id: str) -> dict[str, Any]:
profile_id = profile_id.strip().lower()
item = self._read().get("profiles", {}).get(profile_id)
if not isinstance(item, dict):
raise ValueError(f"Unknown custom provider profile: {profile_id}")
api_key = self.credentials.load(f"custom-provider:{profile_id}", ["api_key"]).get("api_key", "")
if not api_key:
raise ValueError(f"Custom provider profile has no API key: {profile_id}")
return {"id": profile_id, **item, "api_key": api_key}
def activate(self, profile_id: str) -> dict[str, Any]:
profile = self.get(profile_id)
payload = self._read()
payload["active_id"] = profile_id.strip().lower()
self._write(payload)
return {key: value for key, value in profile.items() if key != "api_key"}
def _read(self) -> dict[str, Any]:
if not self.path.exists():
return {"profiles": {}, "active_id": None}
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise ValueError(f"Invalid custom provider store: {exc}") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("profiles"), dict):
raise ValueError("Invalid custom provider store: profiles must be an object")
return payload
def _write(self, payload: dict[str, Any]) -> None:
_write_json_atomically(self.path, payload)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def active_provider_credentials() -> dict[str, str] | None:
store = CustomProviderStore()
payload = store._read()
active_id = str(payload.get("active_id") or "").strip()
if not active_id:
raise RuntimeError("No custom provider is active; select one in Settings")
profile = store.get(active_id)
return {key: str(profile[key]) for key in ("base_url", "model", "api_key")}
+7
View File
@@ -1403,6 +1403,8 @@ def _sync_provider_env() -> None:
_ensure_dotenv()
reset_env_config()
provider = get_env_config().llm.langchain_provider.lower()
if provider == "custom":
return # Vault credentials are passed directly, never exported into ambient env.
if provider in {"openai-codex", "openai_codex"}:
codex_url = get_env_config().llm.openai_codex_base_url
@@ -1721,6 +1723,8 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
configured_responses_api = get_env_config().llm.langchain_use_responses_api
use_responses_api = uses_responses_api(provider, configured_responses_api)
creds = get_llm_credentials(provider, name)
if provider == "custom":
name = model_name or creds["model"]
api_key = creds["api_key"]
_validate_authorization_credential(
api_key,
@@ -1761,6 +1765,9 @@ def build_llm(*, model_name: Optional[str] = None, callbacks: Any = None) -> Any
"vibe_provider": provider,
"vibe_api_key": api_key,
}
langchain_max_tokens = get_env_config().llm.langchain_max_tokens
if langchain_max_tokens:
kwargs["max_tokens"] = langchain_max_tokens
if caps.default_headers:
headers = dict(caps.default_headers)
if caps.name in {"moonshot", "kimi-coding"}:
+262
View File
@@ -0,0 +1,262 @@
"""Tests for the custom OpenAI-compatible provider manager."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import api_server
from src.api import custom_provider_routes
from src.providers.custom_profiles import CustomProviderStore
class _MemoryCredentials:
def __init__(self) -> None:
self.values: dict[tuple[str, str], str] = {}
def get_password(self, service_name: str, username: str) -> str | None:
return self.values.get((service_name, username))
def set_password(self, service_name: str, username: str, password: str) -> None:
self.values[(service_name, username)] = password
def delete_password(self, service_name: str, username: str) -> None:
self.values.pop((service_name, username), None)
@pytest.fixture()
def custom_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> CustomProviderStore:
store = CustomProviderStore(tmp_path / "custom-providers.json", _MemoryCredentials())
monkeypatch.setattr(custom_provider_routes, "_build_store", lambda: store)
return store
@pytest.fixture()
def client() -> TestClient:
return TestClient(api_server.app, client=("127.0.0.1", 50000))
def test_custom_provider_base_url_rejects_embedded_credentials() -> None:
with pytest.raises(ValueError, match="without embedded credentials"):
custom_provider_routes._validate_custom_base_url("https://user:pass@example.test/v1")
@pytest.mark.parametrize("base_url", ["http://127.0.0.1/v1", "http://localhost/v1", "http://169.254.169.254/v1"])
def test_custom_provider_base_url_rejects_local_targets(base_url: str) -> None:
with pytest.raises(ValueError):
custom_provider_routes._validate_custom_base_url(base_url)
def test_store_never_serializes_api_key(tmp_path: Path) -> None:
backend = _MemoryCredentials()
store = CustomProviderStore(tmp_path / "custom-providers.json", backend)
store.save(
profile_id="hilinkup",
label="Hilinkup",
base_url="https://api.example.test/v1",
model="glm-5.3-flash",
api_key="secret-key",
)
payload = json.loads((tmp_path / "custom-providers.json").read_text(encoding="utf-8"))
assert "secret-key" not in json.dumps(payload)
assert backend.values
assert store.list_public()[0]["api_key_configured"] is True
def test_save_requires_a_successful_test(client: TestClient, custom_store: CustomProviderStore) -> None:
response = client.post(
"/settings/custom-providers",
json={
"id": "hilinkup",
"label": "Hilinkup",
"base_url": "https://api.example.test/v1",
"model": "glm-5.3-flash",
"api_key": "temporary-key",
"test_id": "missing-test",
},
)
assert response.status_code == 409
assert custom_store.list_public() == []
assert "temporary-key" not in response.text
def test_test_endpoint_returns_no_secret_and_save_then_requires_confirmation(
client: TestClient,
custom_store: CustomProviderStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_completion(**_: Any) -> dict[str, Any]:
return {"response_preview": "CUSTOM_PROVIDER_TEST_OK", "latency_ms": 12}
monkeypatch.setattr(custom_provider_routes, "_perform_completion_test", fake_completion)
test_response = client.post(
"/settings/custom-providers/test",
json={
"base_url": "https://api.example.test/v1",
"model": "glm-5.3-flash",
"api_key": "temporary-key",
},
)
assert test_response.status_code == 200
test_body = test_response.json()
assert test_body["status"] == "ok"
assert test_body["response_preview"] == "CUSTOM_PROVIDER_TEST_OK"
assert "temporary-key" not in test_response.text
save_payload = {
"id": "hilinkup",
"label": "Hilinkup",
"base_url": "https://api.example.test/v1",
"model": "glm-5.3-flash",
"api_key": "temporary-key",
"test_id": test_body["test_id"],
}
save_response = client.post("/settings/custom-providers", json=save_payload)
assert save_response.status_code == 200
assert "temporary-key" not in save_response.text
activation = client.post("/settings/custom-providers/hilinkup/activate", json={"confirm": False})
assert activation.status_code == 400
def test_activation_requires_explicit_confirmation(
client: TestClient,
custom_store: CustomProviderStore,
monkeypatch: pytest.MonkeyPatch,
) -> None:
custom_store.save(
profile_id="hilinkup",
label="Hilinkup",
base_url="https://api.example.test/v1",
model="glm-5.3-flash",
api_key="secret-key",
)
monkeypatch.setattr(custom_provider_routes, "_activate_profile", lambda profile: {"status": "ok"})
response = client.post(
"/settings/custom-providers/hilinkup/activate",
json={"confirm": False},
)
assert response.status_code == 400
assert response.json()["detail"] == "Explicit confirmation is required to activate a provider"
def test_activate_constructs_a_model_with_one_vault_profile(
client, custom_store, tmp_path, monkeypatch
):
from src.providers import custom_profiles, llm
from src.providers.capabilities import get_llm_credentials
custom_store.save(profile_id="alpha", label="Alpha", base_url="https://alpha.example/v1", model="alpha-model", api_key="alpha-secret")
monkeypatch.setattr(custom_profiles, "CustomProviderStore", lambda: custom_store)
monkeypatch.setattr(api_server, "ENV_PATH", tmp_path / ".env")
monkeypatch.setattr(api_server, "LEGACY_ENV_PATH", tmp_path / "absent-env")
monkeypatch.setenv("OPENAI_API_KEY", "other-provider-secret")
monkeypatch.setenv("OPENAI_BASE_URL", "https://other.example/v1")
# Track process values so pytest restores them after the route updates them.
monkeypatch.setenv("LANGCHAIN_PROVIDER", "openai")
monkeypatch.setenv("LANGCHAIN_MODEL_NAME", "other-model")
monkeypatch.setattr(llm, "_ensure_dotenv", lambda: None)
response = client.post("/settings/custom-providers/alpha/activate", json={"confirm": True})
assert response.status_code == 200
assert "secret" not in response.text
assert "secret" not in (tmp_path / ".env").read_text()
creds = get_llm_credentials("custom", "stale-model")
assert creds == {"provider": "custom", "model": "alpha-model", "base_url": "https://alpha.example/v1", "api_key": "alpha-secret"}
model = llm.build_llm()
assert model.model_name == "alpha-model"
assert model.openai_api_base == "https://alpha.example/v1"
assert model.openai_api_key.get_secret_value() == "alpha-secret"
settings = client.get("/settings/llm").json()
assert settings["provider"] == "custom"
assert settings["model_name"] == "alpha-model"
assert settings["base_url"] == "https://alpha.example/v1"
assert custom_store.list_public()[0]["active"] is True
with pytest.raises(ValueError, match="new id"):
custom_store.save(profile_id="alpha", label="Changed", base_url="https://changed.example/v1", model="changed-model", api_key="changed-key")
assert get_llm_credentials("custom", None)["api_key"] == "alpha-secret"
def test_missing_vault_profile_never_falls_back_to_an_ambient_key(custom_store, monkeypatch):
from src.providers import custom_profiles
from src.providers.capabilities import get_llm_credentials
monkeypatch.setattr(custom_profiles, "CustomProviderStore", lambda: custom_store)
monkeypatch.setenv("OPENAI_API_KEY", "other-provider-secret")
with pytest.raises(RuntimeError, match="No custom provider"):
get_llm_credentials("custom", None)
def test_vault_namespace_does_not_overwrite_a_broker_connection(custom_store):
custom_store.credentials.save("alpha", {"api_key": "broker-secret"})
custom_store.save(profile_id="alpha", label="Alpha", base_url="https://alpha.example/v1", model="alpha", api_key="llm-secret")
assert custom_store.credentials.load("alpha", ["api_key"]) == {"api_key": "broker-secret"}
assert custom_store.get("alpha")["api_key"] == "llm-secret"
def test_parallel_saves_retain_every_profile(custom_store):
from concurrent.futures import ThreadPoolExecutor
def save(i):
return custom_store.save(profile_id=f"p{i}", label=f"P{i}", base_url="https://example.test/v1", model="alpha", api_key="synthetic-key")
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(save, range(12)))
assert len(custom_store.list_public()) == 12
def test_full_completion_url_normalizes_to_the_runtime_api_root():
assert custom_provider_routes._validate_custom_base_url("https://example.test/v1/chat/completions/") == "https://example.test/v1"
with pytest.raises(ValueError):
custom_provider_routes._validate_custom_base_url("https://example.test/v1?api_key=secret")
def test_failed_activation_keeps_previous_profile(custom_store, tmp_path, monkeypatch):
from src.api import settings_routes
from fastapi import HTTPException
for name in ("first", "second"):
custom_store.save(profile_id=name, label=name, base_url=f"https://{name}.test/v1", model=name, api_key=f"{name}-key")
custom_store.activate("first")
def fail_write(_):
raise HTTPException(status_code=503, detail="Unable to save settings")
monkeypatch.setattr(settings_routes, "_persist_settings_updates", fail_write)
with pytest.raises(HTTPException):
settings_routes.activate_custom_provider(custom_store.get("second"), store=custom_store)
assert custom_store._read()["active_id"] == "first"
def test_real_completion_transport_redacts_key_and_sends_expected_request(monkeypatch):
import asyncio
import httpx
actual_client = httpx.AsyncClient
seen = []
def handle(request):
seen.append(request)
return httpx.Response(200, json={"choices": [{"message": {"content": "OK synthetic-secret"}}]})
monkeypatch.setattr(custom_provider_routes, "_reject_private_dns_targets", lambda _: None)
monkeypatch.setattr(custom_provider_routes.httpx, "AsyncClient", lambda **kw: actual_client(transport=httpx.MockTransport(handle), **kw))
result = asyncio.run(custom_provider_routes._perform_completion_test(base_url="https://example.test/v1", model="demo", api_key="synthetic-secret"))
assert result["response_preview"] == "OK [redacted]"
assert str(seen[0].url) == "https://example.test/v1/chat/completions"
assert seen[0].headers["Authorization"] == "Bearer synthetic-secret"
assert json.loads(seen[0].content)["model"] == "demo"
def test_cli_custom_commands_are_registered():
import subprocess
import sys
result = subprocess.run([sys.executable, "-m", "cli.main", "provider", "custom", "--help"], capture_output=True, text=True)
assert result.returncode == 0
assert "activate" in result.stdout
assert "add" in result.stdout
+4
View File
@@ -10,6 +10,10 @@ describe("Vite API proxy config", () => {
expect(config).toContain('"/channels"');
});
it("proxies the portfolio and connections API namespace", () => {
expect(config).toContain('"/api"');
});
it("proxies settings endpoints", () => {
expect(config).toContain('"/settings/llm"');
expect(config).toContain('"/settings/data-sources"');
@@ -419,6 +419,7 @@ export const Composer = memo(forwardRef<ComposerHandle, Props>(function Composer
/>
<textarea
ref={inputRef}
dir="auto"
value={input}
rows={1}
onChange={(e) => setInput(e.target.value)}
@@ -101,7 +101,7 @@ export const MarkdownContent = memo(function MarkdownContent({
);
return (
<div className={proseClassName}>
<div className={`${proseClassName} vt-bidi`} dir="auto">
<MarkdownErrorBoundary content={content}>
{streaming ? structuralMarkdown : (
<Suspense fallback={structuralMarkdown}>
@@ -163,7 +163,7 @@ function CopyButton({ text }: { text: string }) {
return (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
className={`absolute top-2 end-2 p-1.5 rounded-md bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity`}
aria-label={label}
title={label}
>
@@ -208,7 +208,7 @@ export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props
?? (meta?.attachment ? [meta.attachment] : []);
return (
<div className="flex justify-end group">
<div className="max-w-[72%] max-h-[40vh] overflow-y-auto break-words rounded-[18px] bg-muted px-4 py-3 text-[15px] text-foreground leading-relaxed whitespace-pre-wrap">
<div dir="auto" className="max-w-[72%] max-h-[40vh] overflow-y-auto break-words rounded-[18px] bg-muted px-4 py-3 text-[15px] text-foreground leading-relaxed whitespace-pre-wrap">
{meta && (attachments.length > 0 || meta.swarmMode || meta.goalMode) && (
<div className="mb-1.5 flex flex-wrap justify-end gap-1.5 text-[10px] leading-none text-muted-foreground">
{attachments.map((attachment, index) => (
@@ -274,7 +274,7 @@ export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props
<div className="space-y-2">
<div className="flex items-start gap-2 rounded-xl border border-danger/30 bg-danger/5 px-4 py-3">
<XCircle className="h-4 w-4 text-danger shrink-0 mt-0.5" />
<p className="text-sm text-danger leading-relaxed">{msg.content}</p>
<p dir="auto" className="text-sm text-danger leading-relaxed">{msg.content}</p>
</div>
{onRetry && (
<div className="space-y-1.5">
@@ -299,7 +299,7 @@ export const MessageBubble = memo(function MessageBubble({ msg, onRetry }: Props
return (
<div className="flex gap-3">
<AgentAvatar />
<p className="text-sm text-muted-foreground leading-relaxed">{msg.content}</p>
<p dir="auto" className="text-sm text-muted-foreground leading-relaxed">{msg.content}</p>
</div>
);
}
@@ -0,0 +1,199 @@
import { CheckCircle2, KeyRound, Loader2, Plus, Save, Server, ShieldCheck, TestTube2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { api, type CustomProviderProfile } from "@/lib/api";
interface Draft {
id: string;
label: string;
base_url: string;
model: string;
api_key: string;
}
const emptyDraft: Draft = { id: "", label: "", base_url: "", model: "", api_key: "" };
const fieldClass = "w-full rounded-md border bg-background px-3 py-2 text-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:opacity-60";
const labelClass = "text-sm font-medium";
const hintClass = "text-xs text-muted-foreground";
export function CustomProviderManager({ onActivated }: { onActivated?: () => void }) {
const { t } = useTranslation();
const [profiles, setProfiles] = useState<CustomProviderProfile[]>([]);
const [draft, setDraft] = useState<Draft>(emptyDraft);
const [testId, setTestId] = useState<string | null>(null);
const [testPreview, setTestPreview] = useState<string | null>(null);
const [testing, setTesting] = useState(false);
const [testElapsed, setTestElapsed] = useState(0);
const [testError, setTestError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [activating, setActivating] = useState<string | null>(null);
const refresh = async () => {
const result = await api.listCustomProviders();
setProfiles(result.providers);
};
useEffect(() => {
void refresh().catch((error: unknown) => {
toast.error(error instanceof Error ? error.message : t("customProviders.loadFailed"));
});
}, []);
const update = (key: keyof Draft, value: string) => {
setDraft((current) => ({ ...current, [key]: value }));
setTestId(null);
setTestPreview(null);
};
const testProvider = async () => {
setTesting(true);
setTestElapsed(0);
setTestError(null);
setTestId(null);
setTestPreview(null);
try {
const result = await api.testCustomProvider({
base_url: draft.base_url,
model: draft.model,
api_key: draft.api_key,
});
setTestId(result.test_id);
setTestPreview(`${result.response_preview} · ${result.latency_ms} ms`);
toast.success(t("customProviders.testPassed"));
} catch (error: unknown) {
setTestError(error instanceof Error ? error.message : t("customProviders.testFailed"));
toast.error(error instanceof Error ? error.message : t("customProviders.testFailed"));
} finally {
setTesting(false);
setTestElapsed(0);
}
};
useEffect(() => {
if (!testing) return;
const startedAt = Date.now();
const timer = window.setInterval(() => {
setTestElapsed(Math.floor((Date.now() - startedAt) / 1000));
}, 1000);
return () => window.clearInterval(timer);
}, [testing]);
const saveProvider = async () => {
if (!testId) return;
setSaving(true);
try {
await api.saveCustomProvider({ ...draft, test_id: testId });
setDraft(emptyDraft);
setTestId(null);
setTestPreview(null);
await refresh();
toast.success(t("customProviders.saved"));
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t("customProviders.saveFailed"));
} finally {
setSaving(false);
}
};
const activateProvider = async (profile: CustomProviderProfile) => {
if (!window.confirm(t("customProviders.confirm", {label: profile.label}))) return;
setActivating(profile.id);
try {
await api.activateCustomProvider(profile.id);
onActivated?.();
await refresh();
toast.success(t("customProviders.activated", {label: profile.label}));
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : t("customProviders.activationFailed"));
} finally {
setActivating(null);
}
};
return (
<section className="rounded-lg border bg-card p-5 shadow-sm" dir="auto">
<div className="mb-5 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" aria-hidden="true" />
<h2 className="text-base font-semibold">{t("customProviders.title")}</h2>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
{t("customProviders.description")}
</p>
</div>
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-xs text-muted-foreground">
<KeyRound className="h-3 w-3" aria-hidden="true" /> {t("customProviders.keyPrivate")}
</span>
</div>
{profiles.length > 0 ? (
<div className="mb-5 space-y-2" aria-live="polite">
{profiles.map((profile) => (
<div key={profile.id} className="flex flex-col gap-3 rounded-md border bg-muted/20 p-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{profile.label}</span>
{profile.active ? <span className="rounded-full bg-success/10 px-2 py-0.5 text-xs text-success">{t("customProviders.active")}</span> : null}
{profile.api_key_configured ? <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{t("customProviders.keyStored")}</span> : null}
</div>
<div className="break-all font-mono text-xs text-muted-foreground">{profile.model} · {profile.base_url}</div>
</div>
<button type="button" onClick={() => void activateProvider(profile)} disabled={profile.active || activating === profile.id} className="inline-flex min-h-11 shrink-0 items-center justify-center gap-2 rounded-md border px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60">
{activating === profile.id ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <CheckCircle2 className="h-4 w-4" aria-hidden="true" />}
{profile.active ? t("customProviders.active") : t("customProviders.activate")}
</button>
</div>
))}
</div>
) : (
<div className="mb-5 rounded-md border border-dashed bg-muted/10 px-4 py-4 text-sm text-muted-foreground">{t("customProviders.empty")}</div>
)}
<div className="mb-4 flex items-center gap-2 border-t pt-5">
<Plus className="h-4 w-4 text-primary" aria-hidden="true" />
<h3 className="text-sm font-semibold">{t("customProviders.add")}</h3>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<label htmlFor="custom-provider-id" className={labelClass}>{t("customProviders.id")}</label>
<input id="custom-provider-id" disabled={testing || saving} value={draft.id} onChange={(event) => update("id", event.target.value)} className={fieldClass} placeholder="hilinkup" autoComplete="off" />
<span className={hintClass}>{t("customProviders.idHint")}</span>
</div>
<div className="grid gap-2">
<label htmlFor="custom-provider-label" className={labelClass}>{t("customProviders.label")}</label>
<input id="custom-provider-label" disabled={testing || saving} value={draft.label} onChange={(event) => update("label", event.target.value)} className={fieldClass} placeholder={t("customProviders.labelPlaceholder")} autoComplete="off" />
</div>
<div className="grid gap-2 md:col-span-2">
<label htmlFor="custom-provider-base-url" className={labelClass}><Server className="me-1 inline h-3.5 w-3.5" aria-hidden="true" />{t("customProviders.baseUrl")}</label>
<input id="custom-provider-base-url" disabled={testing || saving} value={draft.base_url} onChange={(event) => update("base_url", event.target.value)} className={`${fieldClass} font-mono`} placeholder="https://api.example.com/v1" type="url" autoComplete="url" />
<span className={hintClass}>{t("customProviders.urlHint")}</span>
</div>
<div className="grid gap-2">
<label htmlFor="custom-provider-model" className={labelClass}>{t("customProviders.model")}</label>
<input id="custom-provider-model" disabled={testing || saving} value={draft.model} onChange={(event) => update("model", event.target.value)} className={`${fieldClass} font-mono`} placeholder="glm-5.3-flash" autoComplete="off" />
</div>
<div className="grid gap-2">
<label htmlFor="custom-provider-api-key" className={labelClass}><KeyRound className="me-1 inline h-3.5 w-3.5" aria-hidden="true" />{t("customProviders.key")}</label>
<input id="custom-provider-api-key" disabled={testing || saving} value={draft.api_key} onChange={(event) => update("api_key", event.target.value)} className={`${fieldClass} font-mono`} placeholder={t("customProviders.keyPlaceholder")} type="password" autoComplete="new-password" />
</div>
</div>
{testPreview ? <div className="mt-4 rounded-md border border-success/30 bg-success/10 px-3 py-2 text-sm text-success" role="status"><CheckCircle2 className="me-1 inline h-4 w-4" aria-hidden="true" />{t("customProviders.preview", {preview: testPreview})}</div> : null}
{testError ? <div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">{t("customProviders.error", {error: testError})}</div> : null}
{testing && testElapsed >= 8 ? <div className="mt-4 rounded-md border bg-muted/30 px-3 py-2 text-sm text-muted-foreground" role="status">{t("customProviders.waiting", {seconds: testElapsed})}</div> : null}
<div className="mt-5 flex flex-wrap gap-2">
<button type="button" onClick={() => void testProvider()} disabled={testing || saving || !draft.base_url || !draft.model || !draft.api_key} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-md border px-4 py-2 text-sm font-medium transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60">
{testing ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <TestTube2 className="h-4 w-4" aria-hidden="true" />}
{testing ? t("customProviders.testing") : t("customProviders.test")}
</button>
<button type="button" onClick={() => void saveProvider()} disabled={!testId || saving || testing || !draft.id || !draft.label} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60">
{saving ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Save className="h-4 w-4" aria-hidden="true" />}
{saving ? t("customProviders.saving") : t("customProviders.save")}
</button>
<span className="basis-full text-xs text-muted-foreground">{t("customProviders.saveHint")}</span>
</div>
</section>
);
}
@@ -0,0 +1,107 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CustomProviderManager } from "../CustomProviderManager";
const apiMock = vi.hoisted(() => ({
listCustomProviders: vi.fn(),
testCustomProvider: vi.fn(),
saveCustomProvider: vi.fn(),
activateCustomProvider: vi.fn(),
}));
vi.mock("@/lib/api", () => ({ api: apiMock }));
describe("CustomProviderManager", () => {
beforeEach(() => {
vi.restoreAllMocks();
apiMock.listCustomProviders.mockResolvedValue({ status: "ok", providers: [] });
apiMock.testCustomProvider.mockResolvedValue({
status: "ok",
test_id: "test-123",
response_preview: "PROVIDER_TEST_OK",
latency_ms: 42,
});
apiMock.saveCustomProvider.mockResolvedValue({
status: "ok",
provider: {
id: "demo",
label: "Demo",
base_url: "https://example.com/v1",
model: "demo-model",
api_key_configured: true,
active: false,
},
});
});
it("requires a successful test before saving", async () => {
render(<CustomProviderManager />);
expect(await screen.findByText("No custom providers yet. Add one below and test it before saving.")).toBeInTheDocument();
const saveButton = screen.getByRole("button", { name: "Save tested provider" });
expect(saveButton).toBeDisabled();
fireEvent.change(screen.getByRole("textbox", { name: "API Base URL" }), { target: { value: "https://example.com/v1" } });
fireEvent.change(screen.getByRole("textbox", { name: "Model" }), { target: { value: "demo-model" } });
fireEvent.change(screen.getByLabelText("API key"), { target: { value: "secret-value" } });
fireEvent.change(screen.getByRole("textbox", { name: "Provider ID" }), { target: { value: "demo" } });
fireEvent.change(screen.getByRole("textbox", { name: "Display name" }), { target: { value: "Demo" } });
fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
await waitFor(() => expect(apiMock.testCustomProvider).toHaveBeenCalledWith({
base_url: "https://example.com/v1",
model: "demo-model",
api_key: "secret-value",
}));
expect(await screen.findByText(/Test passed: PROVIDER_TEST_OK/)).toBeInTheDocument();
expect(saveButton).toBeEnabled();
fireEvent.click(saveButton);
await waitFor(() => expect(apiMock.saveCustomProvider).toHaveBeenCalledWith({
id: "demo",
label: "Demo",
base_url: "https://example.com/v1",
model: "demo-model",
api_key: "secret-value",
test_id: "test-123",
}));
});
it("requires confirmation before activating a saved provider", async () => {
apiMock.listCustomProviders.mockResolvedValue({
status: "ok",
providers: [{
id: "demo",
label: "Demo",
base_url: "https://example.com/v1",
model: "demo-model",
api_key_configured: true,
active: false,
}],
});
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<CustomProviderManager />);
fireEvent.click(await screen.findByRole("button", { name: "Activate" }));
expect(confirm).toHaveBeenCalledWith("Activate Demo for new agent requests?");
expect(apiMock.activateCustomProvider).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
fireEvent.click(screen.getByRole("button", { name: "Activate" }));
await waitFor(() => expect(apiMock.activateCustomProvider).toHaveBeenCalledWith("demo"));
});
it("shows a provider test failure inside the manager", async () => {
apiMock.testCustomProvider.mockRejectedValue(new Error("Provider returned HTTP 404"));
render(<CustomProviderManager />);
fireEvent.change(screen.getByRole("textbox", { name: "API Base URL" }), { target: { value: "https://example.com/v1" } });
fireEvent.change(screen.getByRole("textbox", { name: "Model" }), { target: { value: "demo-model" } });
fireEvent.change(screen.getByLabelText("API key"), { target: { value: "synthetic-key" } });
fireEvent.click(screen.getByRole("button", { name: "Test connection" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Provider returned HTTP 404");
expect(screen.getByRole("button", { name: "Test connection" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Save tested provider" })).toBeDisabled();
});
});
+1 -1
View File
@@ -159,7 +159,7 @@ describe("i18n utilities", () => {
// the order the switcher renders, and that "en" stays first so the
// primary-match fallback in Layout resolves regional codes to it.
const codes = SUPPORTED_LANGUAGES.map((l) => l.code);
expect(codes).toEqual(["en", "zh-CN", "ja", "ko", "ar", "es", "de", "pt-BR", "id"]);
expect(codes).toEqual(["en", "zh-CN", "ja", "ko", "ar", "fa", "es", "de", "pt-BR", "id"]);
});
it("accepts zh-CN as an explicit supported language", async () => {
+2
View File
@@ -13,6 +13,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: "ja", label: "日本語", dir: "ltr" as const },
{ code: "ko", label: "한국어", dir: "ltr" as const },
{ code: "ar", label: "العربية", dir: "rtl" as const },
{ code: "fa", label: "فارسی", dir: "rtl" as const },
{ code: "es", label: "Español", dir: "ltr" as const },
{ code: "de", label: "Deutsch", dir: "ltr" as const },
{ code: "pt-BR", label: "Português (Brasil)", dir: "ltr" as const },
@@ -27,6 +28,7 @@ const localeLoaders = {
ja: () => import("./locales/ja.json"),
ko: () => import("./locales/ko.json"),
ar: () => import("./locales/ar.json"),
fa: () => import("./locales/fa.json"),
es: () => import("./locales/es.json"),
de: () => import("./locales/de.json"),
"pt-BR": () => import("./locales/pt-BR.json"),
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "العامل المحدد: {{id}}. سيُقيّم هذا العامل فقط.",
"fundamentalsRequirement": "تحتاج العوامل الأساسية إلى قوائم SEC آمنة زمنيًا ومجموعة أسهم أمريكية (S&P 500).",
"noEvaluableFactors": "تعذر تقييم أي عامل. جرّب فترة تاريخية أطول وتحقق من حقول البيانات المطلوبة ومجموعة الأسهم."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "Gewählter Faktor: {{id}}. Nur dieser Faktor wird bewertet.",
"fundamentalsRequirement": "Fundamentalfaktoren benötigen PIT-sichere SEC-Abschlüsse und ein US-Aktienuniversum (S&P 500).",
"noEvaluableFactors": "Keine Faktoren konnten bewertet werden. Längeren Verlauf wählen und erforderliche Datenfelder sowie Aktienuniversum prüfen."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "Selected factor: {{id}}. Only this factor will be evaluated.",
"fundamentalsRequirement": "Fundamental factors need PIT SEC financial statements and a US stock universe (S&P 500).",
"noEvaluableFactors": "No factors could be evaluated. Try a longer history and check the factor’s required fields and stock universe."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "Factor seleccionado: {{id}}. Solo se evaluará este factor.",
"fundamentalsRequirement": "Los factores fundamentales necesitan estados SEC seguros en el tiempo y un universo de acciones de EE. UU. (S&P 500).",
"noEvaluableFactors": "No se pudo evaluar ningún factor. Amplía el historial y comprueba los campos requeridos y el universo de acciones."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "Faktor dipilih: {{id}}. Hanya faktor ini yang dievaluasi.",
"fundamentalsRequirement": "Faktor fundamental memerlukan laporan SEC yang aman secara PIT dan semesta saham AS (S&P 500).",
"noEvaluableFactors": "Tidak ada faktor yang dapat dievaluasi. Perpanjang riwayat dan periksa bidang data yang diperlukan serta semesta saham."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "選択した因子:{{id}}。この因子のみ評価します。",
"fundamentalsRequirement": "基本面因子にはPIT安全なSEC財務情報と米国株ユニバース(S&P 500)が必要です。",
"noEvaluableFactors": "評価できる因子がありません。期間を延ばし、必要なデータ項目と株式ユニバースを確認してください。"
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "선택 팩터: {{id}}. 이 팩터만 평가합니다.",
"fundamentalsRequirement": "기본적 팩터에는 PIT 안전 SEC 재무제표와 미국 주식 유니버스(S&P 500)가 필요합니다.",
"noEvaluableFactors": "평가 가능한 팩터가 없습니다. 기간을 늘리고 필요한 데이터 필드와 주식 유니버스를 확인하세요."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "Fator selecionado: {{id}}. Apenas este fator será avaliado.",
"fundamentalsRequirement": "Fatores fundamentalistas exigem demonstrações SEC sem dados futuros e um universo de ações dos EUA (S&P 500).",
"noEvaluableFactors": "Nenhum fator pôde ser avaliado. Amplie o histórico e verifique os campos necessários e o universo de ações."
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+35
View File
@@ -2345,5 +2345,40 @@
"selectedFactor": "已选因子:{{id}},本次只评测这一项。",
"fundamentalsRequirement": "基本面因子需要 PIT 安全的 SEC 财务报表,请选择美股股票池(标普 500)。",
"noEvaluableFactors": "没有可评测的因子。请尝试更长的历史区间,并核对因子所需的数据字段和股票池。"
},
"customProviders": {
"loadFailed": "Failed to load custom providers",
"testPassed": "Provider test passed",
"testFailed": "Provider test failed",
"saved": "Provider saved in the local credential vault",
"saveFailed": "Provider save failed",
"activationFailed": "Provider activation failed",
"title": "Custom OpenAI-compatible providers",
"description": "Add any provider with a compatible chat-completions API. The key is tested before saving, then kept in the OS credential vault.",
"keyPrivate": "Key never displayed",
"active": "Active",
"keyStored": "Key stored",
"activate": "Activate",
"empty": "No custom providers yet. Add one below and test it before saving.",
"add": "Add provider",
"id": "Provider ID",
"idHint": "Lowercase letters, numbers, dots, dashes, or underscores.",
"label": "Display name",
"labelPlaceholder": "My provider",
"baseUrl": "API Base URL",
"urlHint": "Use the API root. Do not include an API key in the URL.",
"model": "Model",
"key": "API key",
"keyPlaceholder": "Enter key for one-time test",
"testing": "Testing…",
"test": "Test connection",
"saving": "Saving…",
"save": "Save tested provider",
"saveHint": "Save unlocks only after a successful test. Activation is a separate confirmed action.",
"confirm": "Activate {{label}} for new agent requests?",
"activated": "{{label}} is now active",
"preview": "Test passed: {{preview}}",
"error": "Provider test failed: {{error}}",
"waiting": "The provider is taking longer than usual ({{seconds}}s). The request will continue until the server timeout."
}
}
+26
View File
@@ -120,3 +120,29 @@
/* RTL: mirror directional icons (chevrons, arrows) automatically */
[dir="rtl"] .rtl\:flip-x { transform: scaleX(-1); }
/* ===== Bidi/RTL support for chat content =====
Each markdown block auto-detects its own base direction (first-strong,
like Telegram) instead of inheriting the LTR document direction.
Unlayered on purpose: unlayered author rules win over @layer utilities,
so these override prose-* / Tailwind alignment classes. */
.vt-bidi :is(p, li, h1, h2, h3, h4, h5, h6, blockquote, td, th, figcaption, dd, dt) {
unicode-bidi: plaintext;
text-align: start;
}
.vt-bidi table {
unicode-bidi: plaintext;
}
.vt-bidi pre {
direction: ltr;
text-align: left;
unicode-bidi: isolate;
}
.vt-bidi :not(pre) > code {
direction: ltr;
unicode-bidi: isolate;
}
.vt-bidi .katex,
.vt-bidi .katex * {
direction: ltr;
}
+45
View File
@@ -589,6 +589,25 @@ export const api = {
method: "POST",
body: JSON.stringify(settings),
}),
listCustomProviders: () =>
request<{ status: string; providers: CustomProviderProfile[] }>("/settings/custom-providers"),
testCustomProvider: (settings: { base_url: string; model: string; api_key: string }) =>
request<CustomProviderTestResponse>("/settings/custom-providers/test", {
method: "POST",
body: JSON.stringify(settings),
}),
saveCustomProvider: (settings: CustomProviderSaveRequest) =>
request<{ status: string; provider: CustomProviderProfile }>("/settings/custom-providers", {
method: "POST",
body: JSON.stringify(settings),
}),
activateCustomProvider: (profileId: string) =>
request<{ status: string; active_id: string; model: string; base_url: string }>(
`/settings/custom-providers/${encodeURIComponent(profileId)}/activate`, {
method: "POST",
body: JSON.stringify({ confirm: true }),
},
),
getDataSourceSettings: () => request<DataSourceSettings>("/settings/data-sources"),
updateDataSourceSettings: (settings: UpdateDataSourceSettingsRequest) =>
request<DataSourceSettings>("/settings/data-sources", {
@@ -888,6 +907,32 @@ export interface ListLLMModelsRequest {
refresh?: boolean;
}
export interface CustomProviderProfile {
id: string;
label: string;
base_url: string;
model: string;
api_key_configured: boolean;
active: boolean;
last_tested_at?: string | null;
}
export interface CustomProviderTestResponse {
status: "ok";
test_id: string;
response_preview: string;
latency_ms: number;
}
export interface CustomProviderSaveRequest {
id: string;
label: string;
base_url: string;
model: string;
api_key: string;
test_id: string;
}
export interface LLMModelsResponse {
provider: string;
models: string[];
+5
View File
@@ -3,6 +3,7 @@ import { Database, KeyRound, Loader2, RefreshCw, RotateCcw, Save, Server, Slider
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ChannelSettings } from "@/components/settings/ChannelSettings";
import { CustomProviderManager } from "@/components/settings/CustomProviderManager";
import { ModelPicker } from "@/components/settings/ModelPicker";
import { QVerisSettings } from "@/components/settings/QVerisSettings"; // QVERIS-INTEGRATION
import { SourcePrioritySettings } from "@/components/settings/SourcePrioritySettings";
@@ -604,6 +605,10 @@ export function Settings() {
</section>
</form>
<CustomProviderManager onActivated={() => {
void api.getLLMSettings().then((value) => { setSettings(value); setForm(toForm(value)); });
}} />
<form onSubmit={submitDataSources} className="rounded-lg border bg-card p-5 shadow-sm">
<div className="mb-5 space-y-1">
<div className="flex items-center gap-2">
@@ -13,6 +13,7 @@ const apiMock = vi.hoisted(() => ({
testChannel: vi.fn(),
updateLLMSettings: vi.fn(),
updateDataSourceSettings: vi.fn(),
listCustomProviders: vi.fn().mockResolvedValue({status: "ok", providers: []}),
}));
vi.mock("@/lib/api", async () => {
@@ -12,6 +12,7 @@ const apiMock = vi.hoisted(() => ({
stopChannels: vi.fn(),
updateLLMSettings: vi.fn(),
updateDataSourceSettings: vi.fn(),
listCustomProviders: vi.fn().mockResolvedValue({status: "ok", providers: []}),
}));
vi.mock("@/lib/api", async () => {
+1
View File
@@ -11,6 +11,7 @@ const PROXY_PATHS = [
"/qveris",
"/settings/llm",
"/settings/data-sources",
"/settings/custom-providers",
"/channels",
"/mandate",
"/live",