Merge PR #1264: integrate tested custom providers and Persian locale on current settings
This commit is contained in:
commit
f89b812741
35 files changed
+3839
-7
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
@@ -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"}:
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const PROXY_PATHS = [
|
||||
"/qveris",
|
||||
"/settings/llm",
|
||||
"/settings/data-sources",
|
||||
"/settings/custom-providers",
|
||||
"/channels",
|
||||
"/mandate",
|
||||
"/live",
|
||||
|
||||
Reference in new issue
Block a user