Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions backend/app/api/routes_settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,77 @@
from backend.app.schemas.settings import AppSettings
from backend.app.services.settings_service import get_model_settings, update_model_settings
from __future__ import annotations

from argument_risk_engine.classification.model_provider import ProviderProfile

from backend.app.schemas.settings import (
ActiveProviderRequest,
ActiveProviderResponse,
AppSettings,
ProviderListResponse,
ProviderProfilePatch,
ProviderProfileSchema,
ProviderTestResponse,
)
from backend.app.services.settings_service import (
get_active_model_provider,
get_model_settings,
list_model_providers,
patch_model_provider,
save_model_provider,
set_active_model_provider,
test_model_provider,
update_model_settings,
)
from fastapi import APIRouter

router = APIRouter(prefix="/settings", tags=["settings"])


@router.get("", response_model=AppSettings)
def get_settings() -> AppSettings:
return get_model_settings()


@router.put("", response_model=AppSettings)
def put_settings(settings: AppSettings) -> AppSettings:
return update_model_settings(settings)


@router.get("/model-providers", response_model=ProviderListResponse)
def get_model_providers() -> ProviderListResponse:
return ProviderListResponse(providers=[ProviderProfileSchema(**profile.model_dump()) for profile in list_model_providers()])


@router.post("/model-providers", response_model=ProviderProfileSchema)
def post_model_provider(profile: ProviderProfileSchema) -> ProviderProfileSchema:
saved = save_model_provider(ProviderProfile(**profile.model_dump()))
return ProviderProfileSchema(**saved.model_dump())


@router.patch("/model-providers/{provider_id}", response_model=ProviderProfileSchema)
def patch_model_provider_route(provider_id: str, patch: ProviderProfilePatch) -> ProviderProfileSchema | dict[str, str]:
updated = patch_model_provider(provider_id, patch.model_dump())
if updated is None:
return {"detail": "provider not found"}
return ProviderProfileSchema(**updated.model_dump())


@router.post("/model-providers/{provider_id}/test", response_model=ProviderTestResponse)
def test_model_provider_route(provider_id: str) -> ProviderTestResponse | dict[str, str]:
result = test_model_provider(provider_id)
if result is None:
return {"detail": "provider not found"}
return ProviderTestResponse(**result.model_dump())


@router.get("/active-model-provider", response_model=ActiveProviderResponse)
def get_active_model_provider_route() -> ActiveProviderResponse:
provider_id, provider = get_active_model_provider()
return ActiveProviderResponse(provider_id=provider_id, provider=ProviderProfileSchema(**provider.model_dump()) if provider else None)


@router.post("/active-model-provider", response_model=ActiveProviderResponse)
def post_active_model_provider(payload: ActiveProviderRequest) -> ActiveProviderResponse | dict[str, str]:
provider_id, provider = set_active_model_provider(payload.provider_id)
if provider is None:
return {"detail": "provider not found"}
return ActiveProviderResponse(provider_id=provider_id, provider=ProviderProfileSchema(**provider.model_dump()))
62 changes: 60 additions & 2 deletions backend/app/schemas/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,65 @@
from pydantic import BaseModel
from __future__ import annotations

from typing import Literal

from pydantic import BaseModel, Field

ProviderType = Literal["deterministic", "openai_compatible"]


class ProviderProfileSchema(BaseModel):
provider_id: str
label: str
provider_type: ProviderType = "openai_compatible"
base_url: str = ""
model_name: str = ""
api_key_env_var: str = ""
timeout_seconds: int = 60
max_tokens: int = 2048
temperature: float = 0.0
supports_json_mode: str | bool = "unknown"
supports_streaming: str | bool = "unknown"
enabled: bool = True


class ProviderProfilePatch(BaseModel):
label: str | None = None
provider_type: ProviderType | None = None
base_url: str | None = None
model_name: str | None = None
api_key_env_var: str | None = None
timeout_seconds: int | None = None
max_tokens: int | None = None
temperature: float | None = None
supports_json_mode: str | bool | None = None
supports_streaming: str | bool | None = None
enabled: bool | None = None


class ProviderListResponse(BaseModel):
providers: list[ProviderProfileSchema] = Field(default_factory=list)


class ActiveProviderRequest(BaseModel):
provider_id: str


class ActiveProviderResponse(BaseModel):
provider_id: str
provider: ProviderProfileSchema | None = None


class ProviderTestResponse(BaseModel):
provider_id: str
status: str
latency_ms: int
warnings: list[str] = Field(default_factory=list)
models: list[str] = Field(default_factory=list)
detail: str = ""


class AppSettings(BaseModel):
llm_provider: str = "deterministic"
llm_provider: str = "deterministic_baseline"
model: str = "local-keyword"
temperature: float = 0.0
active_model_provider: str = "deterministic_baseline"
94 changes: 89 additions & 5 deletions backend/app/services/settings_service.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,95 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from argument_risk_engine.classification.llm_client import LLMClient, ProviderTestResult
from argument_risk_engine.classification.model_provider import ProviderProfile
from argument_risk_engine.classification.provider_registry import ProviderRegistry

import yaml
from backend.app.core.paths import DATA_DIR
from backend.app.schemas.settings import AppSettings

_CURRENT = AppSettings()
MODEL_PROFILES_PATH = DATA_DIR / "config" / "model_profiles.yaml"
APP_SETTINGS_PATH = DATA_DIR / "config" / "app_settings.yaml"

_registry = ProviderRegistry(MODEL_PROFILES_PATH)


def get_model_settings() -> AppSettings:
return _CURRENT
payload = _read_yaml(APP_SETTINGS_PATH)
provider_id = payload.get("active_model_provider") or payload.get("llm_provider") or "deterministic_baseline"
return AppSettings(
llm_provider=payload.get("llm_provider", provider_id),
model=payload.get("model", "local-keyword"),
temperature=float(payload.get("temperature", 0.0)),
active_model_provider=provider_id,
)


def update_model_settings(settings: AppSettings) -> AppSettings:
global _CURRENT
_CURRENT = settings
return _CURRENT
provider_id = settings.llm_provider or settings.active_model_provider
payload = settings.model_dump()
payload["active_model_provider"] = settings.active_model_provider or provider_id
payload["llm_provider"] = provider_id
_write_yaml(APP_SETTINGS_PATH, payload)
return get_model_settings()


def list_model_providers() -> list[ProviderProfile]:
return [_sanitize(profile) for profile in _registry.list_profiles()]


def save_model_provider(profile: ProviderProfile) -> ProviderProfile:
return _sanitize(_registry.upsert_profile(profile))


def patch_model_provider(provider_id: str, patch: dict[str, Any]) -> ProviderProfile | None:
patch.pop("api_key", None)
patch.pop("raw_api_key", None)
updated = _registry.patch_profile(provider_id, patch)
return _sanitize(updated) if updated else None


def get_active_model_provider() -> tuple[str, ProviderProfile | None]:
settings = get_model_settings()
profile = _registry.get_profile(settings.active_model_provider)
return settings.active_model_provider, _sanitize(profile) if profile else None


def set_active_model_provider(provider_id: str) -> tuple[str, ProviderProfile | None]:
profile = _registry.get_profile(provider_id)
if profile is None:
return provider_id, None
settings = get_model_settings()
settings.active_model_provider = provider_id
settings.llm_provider = provider_id
settings.model = profile.model_name or settings.model
settings.temperature = float(profile.temperature)
update_model_settings(settings)
return provider_id, _sanitize(profile)


def test_model_provider(provider_id: str) -> ProviderTestResult | None:
profile = _registry.get_profile(provider_id)
if profile is None:
return None
return LLMClient(profile).test_provider()


def _sanitize(profile: ProviderProfile | None) -> ProviderProfile | None:
if profile is None:
return None
return ProviderProfile(**profile.model_dump())


def _read_yaml(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}


def _write_yaml(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8")
7 changes: 6 additions & 1 deletion data/config/app_settings.yaml
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
llm_provider: deterministic
{
"llm_provider": "deterministic_baseline",
"model": "local-keyword",
"temperature": 0.0,
"active_model_provider": "deterministic_baseline"
}
84 changes: 39 additions & 45 deletions data/config/model_profiles.yaml
Original file line number Diff line number Diff line change
@@ -1,95 +1,89 @@
{
"version": "0.2.0",
"version": "1.0",
"items": [
{
"provider_id": "deterministic_baseline",
"label": "Deterministic Baseline",
"provider_type": "deterministic",
"base_url": "",
"model_name": "local-keyword",
"api_key_env_var": "",
"default_model": "",
"timeout_seconds": "30",
"max_tokens": "0",
"temperature": "0",
"supports_json_mode": "n/a",
"supports_streaming": "n/a",
"enabled_default": "yes",
"security_note": "No API key required; must always work offline."
"timeout_seconds": 30,
"max_tokens": 0,
"temperature": 0.0,
"supports_json_mode": true,
"supports_streaming": false,
"enabled": true
},
{
"provider_id": "lm_studio_local",
"label": "LM Studio Local",
"provider_type": "openai_compatible",
"base_url": "http://localhost:1234/v1",
"model_name": "local-model",
"api_key_env_var": "LM_STUDIO_API_KEY",
"default_model": "local-model",
"timeout_seconds": "60",
"max_tokens": "2048",
"temperature": "0",
"timeout_seconds": 60,
"max_tokens": 2048,
"temperature": 0.0,
"supports_json_mode": "unknown",
"supports_streaming": "yes",
"enabled_default": "no",
"security_note": "Dashboard must not store raw key; local servers may accept dummy key."
"supports_streaming": true,
"enabled": true
},
{
"provider_id": "ollama_local",
"label": "Ollama Local",
"provider_type": "openai_compatible",
"base_url": "http://localhost:11434/v1",
"model_name": "qwen3:8b",
"api_key_env_var": "OLLAMA_API_KEY",
"default_model": "qwen3:8b",
"timeout_seconds": "60",
"max_tokens": "2048",
"temperature": "0",
"timeout_seconds": 60,
"max_tokens": 2048,
"temperature": 0.0,
"supports_json_mode": "unknown",
"supports_streaming": "yes",
"enabled_default": "no",
"security_note": "Use OpenAI-compatible endpoint; avoid assuming JSON mode."
"supports_streaming": true,
"enabled": true
},
{
"provider_id": "openai_remote",
"label": "OpenAI Remote",
"provider_type": "openai_compatible",
"base_url": "https://api.openai.com/v1",
"model_name": "gpt-4.1-mini",
"api_key_env_var": "OPENAI_API_KEY",
"default_model": "gpt-4.1-mini",
"timeout_seconds": "60",
"max_tokens": "2048",
"temperature": "0",
"supports_json_mode": "yes",
"supports_streaming": "yes",
"enabled_default": "no",
"security_note": "Backend reads key from environment variable only."
"timeout_seconds": 60,
"max_tokens": 2048,
"temperature": 0.0,
"supports_json_mode": true,
"supports_streaming": true,
"enabled": true
},
{
"provider_id": "openrouter_remote",
"label": "OpenRouter Remote",
"provider_type": "openai_compatible",
"base_url": "https://openrouter.ai/api/v1",
"model_name": "",
"api_key_env_var": "OPENROUTER_API_KEY",
"default_model": "",
"timeout_seconds": "60",
"max_tokens": "2048",
"temperature": "0",
"timeout_seconds": 60,
"max_tokens": 2048,
"temperature": 0.0,
"supports_json_mode": "model_dependent",
"supports_streaming": "yes",
"enabled_default": "no",
"security_note": "Treat as OpenAI-compatible; do not hardcode model names."
"supports_streaming": true,
"enabled": true
},
{
"provider_id": "custom_openai_compatible",
"label": "Custom OpenAI-Compatible",
"provider_type": "openai_compatible",
"base_url": "",
"model_name": "",
"api_key_env_var": "CUSTOM_LLM_API_KEY",
"default_model": "",
"timeout_seconds": "60",
"max_tokens": "2048",
"temperature": "0",
"timeout_seconds": 60,
"max_tokens": 2048,
"temperature": 0.0,
"supports_json_mode": "unknown",
"supports_streaming": "unknown",
"enabled_default": "no",
"security_note": "User-configurable base_url and model_name; never expose raw key."
"enabled": false
}
]
}
}
Loading