diff --git a/README.md b/README.md index 3e0df00..beb9aec 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ Try it: ### Perplexity AI Integration - **Web search**: AI-powered search with citations (`perplexity` tool) - **Reasoning**: Complex analysis with step-by-step reasoning (`perplexity_reason` tool) -- Requires `PERPLEXITY_API_KEY` environment variable +- Requires `PERPLEXITY_API_KEY` environment variable (overridable at runtime) +- **Model allowlist (opt-in)**: only `sonar` is enabled by default; `sonar-pro`, + `sonar-reasoning`, `sonar-reasoning-pro`, and `sonar-deep-research` must be enabled + explicitly to control cost (see [docs/CONFIGURATION.md](docs/CONFIGURATION.md#perplexity-ai)) ### Monitoring Dashboard - Real-time request statistics and cache metrics diff --git a/docs/API.md b/docs/API.md index 7091426..1e81acf 100644 --- a/docs/API.md +++ b/docs/API.md @@ -89,10 +89,16 @@ Search the web using Perplexity AI. Requires `PERPLEXITY_API_KEY` environment va | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `messages` | array | Yes | - | Conversation messages with `role` and `content` | -| `model` | string | No | sonar | Model: "sonar" or "sonar-pro" | +| `model` | string | No | sonar | Model name. Only enabled (opt-in) models are accepted — see note below | | `temperature` | number | No | 0.3 | Creativity 0-2 (lower = focused) | | `max_tokens` | integer | No | 4000 | Maximum response length | +> **Model allowlist:** Only `sonar` is enabled by default. `sonar-pro`, +> `sonar-reasoning`, `sonar-reasoning-pro`, and `sonar-deep-research` must be +> enabled (opt-in) via `PERPLEXITY_ENABLED_MODELS` or the `perplexity_enabled_models` +> runtime config. A disabled model returns an error without making an API call. +> See [CONFIGURATION.md](CONFIGURATION.md#model-allowlist-opt-in). + **Returns:** - `content`: AI-generated response with citation markers - `model`: Model used @@ -103,7 +109,12 @@ Search the web using Perplexity AI. Requires `PERPLEXITY_API_KEY` environment va ### 6. `perplexity_reason` -Complex reasoning tasks using Perplexity's reasoning model. Requires `PERPLEXITY_API_KEY`. +Complex reasoning tasks using Perplexity's reasoning model (`sonar-reasoning-pro`). +Requires `PERPLEXITY_API_KEY`. + +> **Disabled by default:** `sonar-reasoning-pro` is opt-in, so this tool returns a +> "model not enabled" error until you enable it via `PERPLEXITY_ENABLED_MODELS` or +> the `perplexity_enabled_models` runtime config. **Parameters:** | Parameter | Type | Required | Default | Description | @@ -263,10 +274,11 @@ MCP resources provide read-only data access via URI-based addressing. Access res | URI | Description | |-----|-------------| -| `config://current` | Current runtime configuration | +| `config://current` | Current runtime configuration (API key masked) | | `config://defaults` | Default configuration values | | `config://scraping` | Scraping settings (timeout, retries, concurrency) | | `config://cache` | Cache settings (TTLs, directory) | +| `config://perplexity` | Perplexity settings (enabled/available models, key status) | ### Server Resources diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 93b7ab0..37ed8b7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -35,13 +35,49 @@ python -m scraper_mcp --disable-resources --disable-prompts | Variable | Default | Description | |----------|---------|-------------| -| `PERPLEXITY_API_KEY` | - | API key (enables AI tools when set) | -| `PERPLEXITY_MODEL` | sonar | Default model | +| `PERPLEXITY_API_KEY` | - | API key (enables AI tools when set). Overridable at runtime. | +| `PERPLEXITY_ENABLED_MODELS` | `sonar` | Comma-separated allowlist of usable models (opt-in). | +| `PERPLEXITY_MODEL` | sonar | Default model for the `perplexity` tool | +| `PERPLEXITY_REASONING_MODEL` | sonar-reasoning-pro | Model used by the `perplexity_reason` tool | | `PERPLEXITY_TEMPERATURE` | 0.3 | Default temperature | | `PERPLEXITY_MAX_TOKENS` | 4000 | Default max tokens | Get your API key from [Perplexity AI](https://www.perplexity.ai/). +#### Model allowlist (opt-in) + +To control cost, only `sonar` is enabled by default. The more expensive models — +`sonar-pro`, `sonar-reasoning`, `sonar-reasoning-pro`, and `sonar-deep-research` — +must be explicitly enabled. Requesting a disabled model returns an error **without** +making a (billable) API call. + +> ⚠️ Because `sonar-reasoning-pro` is disabled by default, the `perplexity_reason` +> tool returns a "model not enabled" error until you opt that model in. + +Enable models at startup via the environment: + +```bash +PERPLEXITY_ENABLED_MODELS=sonar,sonar-pro,sonar-reasoning-pro +``` + +…or override the API key and enabled models at runtime via the admin API (changes +are not persisted and reset on restart): + +```bash +# Toggle which models are enabled +curl -X POST http://localhost:8000/api/config \ + -H 'Content-Type: application/json' \ + -d '{"config": {"perplexity_enabled_models": ["sonar", "sonar-pro"]}}' + +# Override the API key (the GET endpoint returns it masked, e.g. "pplx...7890") +curl -X POST http://localhost:8000/api/config \ + -H 'Content-Type: application/json' \ + -d '{"config": {"perplexity_api_key": "pplx-..."}}' +``` + +Unknown model names are rejected. Available models are advertised under +`available_perplexity_models` in `GET /api/config`. + --- ## Proxy Configuration diff --git a/src/scraper_mcp/admin/service.py b/src/scraper_mcp/admin/service.py index bb4bd89..8d8e1f5 100644 --- a/src/scraper_mcp/admin/service.py +++ b/src/scraper_mcp/admin/service.py @@ -8,12 +8,35 @@ from scraper_mcp.cache import clear_all_cache, get_cache_stats from scraper_mcp.metrics import get_metrics +from scraper_mcp.models.perplexity import ( + DEFAULT_ENABLED_PERPLEXITY_MODELS, + PERPLEXITY_MODELS, +) logger = logging.getLogger(__name__) # Default concurrency limit for batch operations DEFAULT_CONCURRENCY = 8 + +def _parse_enabled_models(raw: str | None) -> list[str]: + """Parse a comma-separated PERPLEXITY_ENABLED_MODELS env value. + + Unknown model names are ignored. Falls back to the default allowlist when + the value is empty or yields no valid models. + + Args: + raw: Comma-separated model names (e.g. "sonar,sonar-pro") + + Returns: + Ordered list of valid, enabled model names + """ + if not raw: + return list(DEFAULT_ENABLED_PERPLEXITY_MODELS) + models = [m.strip() for m in raw.split(",") if m.strip() in PERPLEXITY_MODELS] + return models or list(DEFAULT_ENABLED_PERPLEXITY_MODELS) + + # Runtime configuration overrides (not persisted) _runtime_config: dict[str, Any] = { "concurrency": DEFAULT_CONCURRENCY, @@ -27,6 +50,9 @@ "https_proxy": "", "no_proxy": "", "verify_ssl": False, # SSL certificate verification (disabled by default) + # Perplexity settings (overridable at runtime via /api/config) + "perplexity_api_key": os.getenv("PERPLEXITY_API_KEY", ""), + "perplexity_enabled_models": _parse_enabled_models(os.getenv("PERPLEXITY_ENABLED_MODELS")), } # Initialize proxy settings from environment variables if present @@ -66,6 +92,29 @@ def get_config(key: str, default: Any = None) -> Any: return _runtime_config.get(key, default) +def _mask_api_key(value: str) -> str: + """Mask an API key for safe display, preserving a recognizable prefix/suffix. + + Args: + value: The raw API key (may be empty) + + Returns: + Masked representation that never reveals the full secret + """ + if not value: + return "" + if len(value) <= 8: + return "***" + return f"{value[:4]}...{value[-4:]}" + + +def _public_config() -> dict[str, Any]: + """Return a copy of the runtime config with secrets masked for display.""" + public = dict(_runtime_config) + public["perplexity_api_key"] = _mask_api_key(_runtime_config.get("perplexity_api_key", "")) + return public + + def get_stats() -> dict[str, Any]: """Get server statistics and metrics. @@ -94,7 +143,7 @@ def get_current_config() -> dict[str, Any]: Dictionary with current config, defaults, and note """ return { - "config": _runtime_config, + "config": _public_config(), "defaults": { "concurrency": DEFAULT_CONCURRENCY, "default_timeout": 30, @@ -107,7 +156,10 @@ def get_current_config() -> dict[str, Any]: "https_proxy": "", "no_proxy": "", "verify_ssl": False, + "perplexity_api_key": "", + "perplexity_enabled_models": list(DEFAULT_ENABLED_PERPLEXITY_MODELS), }, + "available_perplexity_models": list(PERPLEXITY_MODELS), "note": "Changes are not persisted and will reset on server restart", } @@ -136,6 +188,8 @@ def update_config(config_updates: dict[str, Any]) -> dict[str, Any]: "https_proxy", "no_proxy", "verify_ssl", + "perplexity_api_key", + "perplexity_enabled_models", } updated = [] @@ -161,12 +215,27 @@ def update_config(config_updates: dict[str, Any]) -> dict[str, Any]: elif key in ("http_proxy", "https_proxy", "no_proxy") and isinstance(value, str): _runtime_config[key] = value updated.append(key) + elif key == "perplexity_api_key" and isinstance(value, str): + _runtime_config[key] = value.strip() + updated.append(key) + elif key == "perplexity_enabled_models" and isinstance(value, list): + # Only accept known model names; reject unknown values to surface typos + cleaned = [m for m in value if isinstance(m, str) and m in PERPLEXITY_MODELS] + if len(cleaned) == len(value): + _runtime_config[key] = cleaned + updated.append(key) + else: + invalid = [m for m in value if m not in PERPLEXITY_MODELS] + raise ValueError( + f"Unknown Perplexity model(s): {invalid}. " + f"Valid models: {list(PERPLEXITY_MODELS)}" + ) return { "status": "success", "message": f"Updated {len(updated)} config value(s)", "updated": updated, - "current_config": _runtime_config, + "current_config": _public_config(), } diff --git a/src/scraper_mcp/models/perplexity.py b/src/scraper_mcp/models/perplexity.py index f7d2869..09d2f5c 100644 --- a/src/scraper_mcp/models/perplexity.py +++ b/src/scraper_mcp/models/perplexity.py @@ -6,6 +6,19 @@ from pydantic import BaseModel, Field +# All known Perplexity Sonar models. Used to validate the enabled-models allowlist. +PERPLEXITY_MODELS: tuple[str, ...] = ( + "sonar", + "sonar-pro", + "sonar-reasoning", + "sonar-reasoning-pro", + "sonar-deep-research", +) + +# Models enabled by default. Only the cheap `sonar` model is on out of the box; +# the more expensive models must be enabled explicitly (opt-in) to control cost. +DEFAULT_ENABLED_PERPLEXITY_MODELS: tuple[str, ...] = ("sonar",) + class PerplexityResponse(BaseModel): """Response model for Perplexity AI operations.""" diff --git a/src/scraper_mcp/resources/config.py b/src/scraper_mcp/resources/config.py index edea2ef..5247cba 100644 --- a/src/scraper_mcp/resources/config.py +++ b/src/scraper_mcp/resources/config.py @@ -9,6 +9,10 @@ DEFAULT_CONCURRENCY, get_current_config, ) +from scraper_mcp.models.perplexity import ( + DEFAULT_ENABLED_PERPLEXITY_MODELS, + PERPLEXITY_MODELS, +) if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP @@ -27,6 +31,8 @@ "https_proxy": "", "no_proxy": "", "verify_ssl": False, + "perplexity_api_key": "", + "perplexity_enabled_models": list(DEFAULT_ENABLED_PERPLEXITY_MODELS), } @@ -85,3 +91,28 @@ def config_cache_resource() -> str: }, } return json.dumps(cache_config, indent=2) + + @mcp.resource("config://perplexity") + def config_perplexity_resource() -> str: + """Perplexity-specific configuration settings. + + Reports which models are enabled (opt-in), the full list of available + models, and whether an API key is configured. The API key itself is + masked and never exposed. + """ + config_data = get_current_config() + config = config_data.get("config", {}) + + perplexity_config = { + "api_key": config.get("perplexity_api_key", ""), # already masked + "api_key_configured": bool(config.get("perplexity_api_key")), + "enabled_models": config.get( + "perplexity_enabled_models", list(DEFAULT_ENABLED_PERPLEXITY_MODELS) + ), + "available_models": list(PERPLEXITY_MODELS), + "description": { + "enabled_models": "Models that may be used. Others are rejected (opt-in).", + "default": f"Only {list(DEFAULT_ENABLED_PERPLEXITY_MODELS)} enabled by default.", + }, + } + return json.dumps(perplexity_config, indent=2) diff --git a/src/scraper_mcp/services/perplexity_service.py b/src/scraper_mcp/services/perplexity_service.py index 10b0d6f..a9112d0 100644 --- a/src/scraper_mcp/services/perplexity_service.py +++ b/src/scraper_mcp/services/perplexity_service.py @@ -7,8 +7,12 @@ import time from typing import Any +from scraper_mcp.admin.service import get_config from scraper_mcp.metrics import record_request -from scraper_mcp.models.perplexity import PerplexityResponse +from scraper_mcp.models.perplexity import ( + DEFAULT_ENABLED_PERPLEXITY_MODELS, + PerplexityResponse, +) # Perplexity SDK is optional - only import if available try: @@ -67,33 +71,66 @@ class PerplexityService: This service provides methods for chat completions and reasoning tasks using Perplexity's web-grounded AI models. - Configuration is via environment variables: - - PERPLEXITY_API_KEY: Required API key (tools disabled if missing) - - PERPLEXITY_MODEL: Default model (default: sonar) + Settings are sourced from runtime config (overridable at runtime via the + /api/config endpoint), which is itself seeded from environment variables: + - PERPLEXITY_API_KEY: API key (tools disabled if missing). Runtime-overridable. + - PERPLEXITY_ENABLED_MODELS: Comma-separated allowlist (default: sonar). + Runtime-overridable. Models not on the list are rejected (opt-in). + - PERPLEXITY_MODEL: Default model for the chat tool (default: sonar) + - PERPLEXITY_REASONING_MODEL: Model for the reason tool (default: sonar-reasoning-pro) - PERPLEXITY_TEMPERATURE: Default temperature (default: 0.3) - PERPLEXITY_MAX_TOKENS: Default max tokens (default: 4000) """ def __init__(self) -> None: """Initialize the Perplexity service with configuration from environment.""" - self.api_key = os.getenv("PERPLEXITY_API_KEY", "") self.default_model = os.getenv("PERPLEXITY_MODEL", "sonar") self.default_temperature = float(os.getenv("PERPLEXITY_TEMPERATURE", "0.3")) self.default_max_tokens = int(os.getenv("PERPLEXITY_MAX_TOKENS", "4000")) - self.reasoning_model = "sonar-reasoning-pro" + self.reasoning_model = os.getenv("PERPLEXITY_REASONING_MODEL", "sonar-reasoning-pro") - # Initialize client if API key is available + # Client is built lazily and rebuilt when the API key changes at runtime. self._client: Any = None - if self.api_key and PERPLEXITY_AVAILABLE: - self._client = Perplexity(api_key=self.api_key) + self._client_key: str | None = None + + @property + def api_key(self) -> str: + """Current API key, preferring the runtime override then the environment.""" + return str( + get_config("perplexity_api_key", "") or os.getenv("PERPLEXITY_API_KEY", "") or "" + ) + + @property + def enabled_models(self) -> list[str]: + """Models the user has opted into. Defaults to the cheap `sonar` model only.""" + models = get_config("perplexity_enabled_models", None) + if not models: + return list(DEFAULT_ENABLED_PERPLEXITY_MODELS) + return list(models) + + def _get_client(self) -> Any: + """Return a Perplexity client, (re)building it when the API key changes. + + Returns None when no API key is configured or the SDK is unavailable. + """ + key = self.api_key + if not key or not PERPLEXITY_AVAILABLE: + self._client = None + self._client_key = None + return None + if self._client is None or self._client_key != key: + self._client = Perplexity(api_key=key) + self._client_key = key + return self._client @classmethod def is_available(cls) -> bool: """Check if Perplexity service is available. - Returns True if: - - PERPLEXITY_API_KEY environment variable is set - - perplexity SDK is installed + Used as the startup gate for registering the Perplexity tools, so it + checks the environment directly. Returns True if the perplexity SDK is + installed and PERPLEXITY_API_KEY is set. The API key can still be + overridden at runtime via /api/config once the tools are registered. """ return bool(os.getenv("PERPLEXITY_API_KEY")) and PERPLEXITY_AVAILABLE @@ -115,7 +152,8 @@ async def chat( Returns: PerplexityResponse with content, citations, and usage stats """ - if not self._client: + client = self._get_client() + if not client: prompt = _extract_prompt(messages) record_request( url=f'perplexity://{model or self.default_model} "{prompt}"', @@ -139,6 +177,26 @@ async def chat( prompt = _extract_prompt(messages) metrics_url = f'perplexity://{model} "{prompt}"' + # Enforce the enabled-models allowlist (opt-in). Models not enabled are + # rejected before any (billable) API call is made. + enabled = self.enabled_models + if model not in enabled: + record_request( + url=metrics_url, + success=False, + status_code=403, + elapsed_ms=0, + attempts=1, + error=f"Model '{model}' is not enabled", + request_type="perplexity", + ) + return self._error_response( + f"Model '{model}' is not enabled. Enabled models: {enabled}. " + f"Enable it via the 'perplexity_enabled_models' setting (opt-in) " + f"to control cost.", + model, + ) + start_time = time.time() try: @@ -146,7 +204,7 @@ async def chat( loop = asyncio.get_event_loop() completion = await loop.run_in_executor( None, - lambda: self._client.chat.completions.create( + lambda: client.chat.completions.create( messages=messages, model=model, temperature=temperature, diff --git a/src/scraper_mcp/tools/router.py b/src/scraper_mcp/tools/router.py index 0e7edeb..01b0571 100644 --- a/src/scraper_mcp/tools/router.py +++ b/src/scraper_mcp/tools/router.py @@ -272,7 +272,10 @@ async def perplexity( Args: messages: Array of conversation messages, each with 'role' (system/user/assistant) and 'content' keys. Example: [{"role": "user", "content": "What is AI?"}] - model: Model to use - "sonar" for general queries, "sonar-pro" for complex analysis + model: Model to use. Only "sonar" is enabled by default; "sonar-pro", + "sonar-reasoning", "sonar-reasoning-pro", and "sonar-deep-research" + must be enabled (opt-in) via the 'perplexity_enabled_models' setting. + Requesting a disabled model returns an error without making an API call. temperature: Response creativity (0-2, default: 0.3). Lower = more focused. max_tokens: Maximum response length in tokens (default: 4000) @@ -298,6 +301,10 @@ async def perplexity_reason( Accepts a query string and returns a comprehensive reasoned response. Uses the sonar-reasoning-pro model optimized for analytical and multi-step reasoning. + Note: sonar-reasoning-pro is disabled by default and must be enabled (opt-in) + via the 'perplexity_enabled_models' setting. Until enabled, this tool returns + an error without making an API call. + Args: query: The query or problem to reason about. Can be a complex question requiring analysis, comparison, or multi-step reasoning. diff --git a/tests/test_admin_config.py b/tests/test_admin_config.py new file mode 100644 index 0000000..9003e95 --- /dev/null +++ b/tests/test_admin_config.py @@ -0,0 +1,91 @@ +"""Tests for admin runtime configuration, including Perplexity settings.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest + +from scraper_mcp.admin import service as admin_service +from scraper_mcp.admin.service import ( + _mask_api_key, + _parse_enabled_models, + get_config, + get_current_config, + update_config, +) + + +@pytest.fixture(autouse=True) +def _restore_runtime_config() -> Iterator[None]: + """Snapshot and restore the global runtime config around each test.""" + snapshot = dict(admin_service._runtime_config) + yield + admin_service._runtime_config.clear() + admin_service._runtime_config.update(snapshot) + + +class TestMaskApiKey: + """Tests for _mask_api_key.""" + + def test_empty(self) -> None: + assert _mask_api_key("") == "" + + def test_short_key_fully_masked(self) -> None: + assert _mask_api_key("abc123") == "***" + + def test_long_key_shows_prefix_and_suffix(self) -> None: + masked = _mask_api_key("pplx-1234567890abcdef") + assert masked == "pplx...cdef" + assert "567890" not in masked + + +class TestParseEnabledModels: + """Tests for _parse_enabled_models.""" + + def test_none_returns_default(self) -> None: + assert _parse_enabled_models(None) == ["sonar"] + + def test_empty_returns_default(self) -> None: + assert _parse_enabled_models("") == ["sonar"] + + def test_parses_comma_separated(self) -> None: + assert _parse_enabled_models("sonar, sonar-pro") == ["sonar", "sonar-pro"] + + def test_ignores_unknown_models(self) -> None: + assert _parse_enabled_models("sonar,not-a-model") == ["sonar"] + + def test_all_unknown_falls_back_to_default(self) -> None: + assert _parse_enabled_models("bogus,nope") == ["sonar"] + + +class TestUpdateConfigPerplexity: + """Tests for updating Perplexity settings via update_config.""" + + def test_update_api_key_stored_plaintext_but_masked_in_output(self) -> None: + result = update_config({"perplexity_api_key": "pplx-secret-1234567890"}) + + assert "perplexity_api_key" in result["updated"] + # Stored value is the real key for the service to use + assert get_config("perplexity_api_key") == "pplx-secret-1234567890" + # But the returned/displayed value is masked + assert result["current_config"]["perplexity_api_key"] == "pplx...7890" + + def test_get_current_config_masks_api_key(self) -> None: + update_config({"perplexity_api_key": "pplx-secret-1234567890"}) + current = get_current_config() + assert current["config"]["perplexity_api_key"] == "pplx...7890" + assert "available_perplexity_models" in current + + def test_enable_models_opt_in(self) -> None: + result = update_config({"perplexity_enabled_models": ["sonar", "sonar-reasoning-pro"]}) + assert "perplexity_enabled_models" in result["updated"] + assert get_config("perplexity_enabled_models") == ["sonar", "sonar-reasoning-pro"] + + def test_unknown_model_rejected(self) -> None: + with pytest.raises(ValueError, match="Unknown Perplexity model"): + update_config({"perplexity_enabled_models": ["sonar", "gpt-4"]}) + + def test_non_list_enabled_models_ignored(self) -> None: + result = update_config({"perplexity_enabled_models": "sonar"}) + assert "perplexity_enabled_models" not in result["updated"] diff --git a/tests/test_perplexity.py b/tests/test_perplexity.py index 9e7a731..ac8a744 100644 --- a/tests/test_perplexity.py +++ b/tests/test_perplexity.py @@ -11,6 +11,26 @@ from scraper_mcp.models.perplexity import PerplexityResponse +def _fake_get_config( + api_key: str = "test-key", + enabled: tuple[str, ...] = ("sonar",), +) -> Any: + """Build a stand-in for admin get_config that controls Perplexity settings. + + Patch onto ``scraper_mcp.services.perplexity_service.get_config`` so the + service reads deterministic runtime config in tests. + """ + + def _get(key: str, default: Any = None) -> Any: + if key == "perplexity_api_key": + return api_key + if key == "perplexity_enabled_models": + return list(enabled) + return default + + return _get + + class TestPerplexityService: """Tests for PerplexityService.""" @@ -197,17 +217,157 @@ async def test_reason_uses_reasoning_model(self) -> None: "scraper_mcp.services.perplexity_service.Perplexity", return_value=mock_client, ): - from scraper_mcp.services.perplexity_service import PerplexityService + # Reasoning model is opt-in; enable it for this test + with patch( + "scraper_mcp.services.perplexity_service.get_config", + side_effect=_fake_get_config(enabled=("sonar", "sonar-reasoning-pro")), + ): + from scraper_mcp.services.perplexity_service import PerplexityService - service = PerplexityService() - response = await service.reason(query="Compare solar vs wind energy") + service = PerplexityService() + response = await service.reason(query="Compare solar vs wind energy") - # Verify the reasoning model was used - call_args = mock_client.chat.completions.create.call_args - assert call_args.kwargs["model"] == "sonar-reasoning-pro" + # Verify the reasoning model was used + call_args = mock_client.chat.completions.create.call_args + assert call_args.kwargs["model"] == "sonar-reasoning-pro" - assert response.content == "Reasoned response about the topic." - assert response.model == "sonar-reasoning-pro" + assert response.content == "Reasoned response about the topic." + assert response.model == "sonar-reasoning-pro" + + @pytest.mark.asyncio + async def test_reason_blocked_by_default(self) -> None: + """reason() returns a gating error when the reasoning model is not enabled.""" + mock_client = Mock() + mock_client.chat.completions.create = Mock() + + with patch.dict(os.environ, {"PERPLEXITY_API_KEY": "test-key"}): + with patch("scraper_mcp.services.perplexity_service.PERPLEXITY_AVAILABLE", True): + with patch( + "scraper_mcp.services.perplexity_service.Perplexity", + return_value=mock_client, + ): + with patch( + "scraper_mcp.services.perplexity_service.get_config", + side_effect=_fake_get_config(enabled=("sonar",)), + ): + from scraper_mcp.services.perplexity_service import PerplexityService + + service = PerplexityService() + response = await service.reason(query="Anything") + + # No API call should have been made + mock_client.chat.completions.create.assert_not_called() + assert response.content == "" + assert "not enabled" in response.metadata["error"] + assert response.model == "sonar-reasoning-pro" + + +class TestPerplexityModelGating: + """Tests for the enabled-models allowlist (opt-in) enforcement.""" + + @pytest.mark.asyncio + async def test_disabled_model_rejected_without_api_call(self) -> None: + """Requesting a disabled model returns an error and makes no API call.""" + mock_client = Mock() + mock_client.chat.completions.create = Mock() + + with patch.dict(os.environ, {"PERPLEXITY_API_KEY": "test-key"}): + with patch("scraper_mcp.services.perplexity_service.PERPLEXITY_AVAILABLE", True): + with patch( + "scraper_mcp.services.perplexity_service.Perplexity", + return_value=mock_client, + ): + with patch( + "scraper_mcp.services.perplexity_service.get_config", + side_effect=_fake_get_config(enabled=("sonar",)), + ): + from scraper_mcp.services.perplexity_service import PerplexityService + + service = PerplexityService() + response = await service.chat( + messages=[{"role": "user", "content": "hi"}], + model="sonar-pro", + ) + + mock_client.chat.completions.create.assert_not_called() + assert response.content == "" + assert "sonar-pro" in response.metadata["error"] + assert "not enabled" in response.metadata["error"] + + @pytest.mark.asyncio + async def test_opted_in_model_allowed(self) -> None: + """A model that has been opted into passes the gate and calls the API.""" + mock_choice = Mock() + mock_choice.message = Mock() + mock_choice.message.content = "pro response" + mock_completion = Mock() + mock_completion.choices = [mock_choice] + mock_completion.citations = [] + mock_completion.usage = Mock(prompt_tokens=1, completion_tokens=2, total_tokens=3) + mock_completion.id = "req_pro" + + mock_client = Mock() + mock_client.chat.completions.create = Mock(return_value=mock_completion) + + with patch.dict(os.environ, {"PERPLEXITY_API_KEY": "test-key"}): + with patch("scraper_mcp.services.perplexity_service.PERPLEXITY_AVAILABLE", True): + with patch( + "scraper_mcp.services.perplexity_service.Perplexity", + return_value=mock_client, + ): + with patch( + "scraper_mcp.services.perplexity_service.get_config", + side_effect=_fake_get_config(enabled=("sonar", "sonar-pro")), + ): + from scraper_mcp.services.perplexity_service import PerplexityService + + service = PerplexityService() + response = await service.chat( + messages=[{"role": "user", "content": "hi"}], + model="sonar-pro", + ) + + mock_client.chat.completions.create.assert_called_once() + assert response.content == "pro response" + assert response.model == "sonar-pro" + + @pytest.mark.asyncio + async def test_runtime_api_key_override_builds_client(self) -> None: + """The client is built from the runtime-config API key (override path).""" + mock_choice = Mock() + mock_choice.message = Mock() + mock_choice.message.content = "ok" + mock_completion = Mock() + mock_completion.choices = [mock_choice] + mock_completion.citations = [] + mock_completion.usage = Mock(prompt_tokens=1, completion_tokens=1, total_tokens=2) + mock_completion.id = "req_override" + + mock_client = Mock() + mock_client.chat.completions.create = Mock(return_value=mock_completion) + + # No env key at all; the key comes solely from runtime config + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("PERPLEXITY_API_KEY", None) + with patch("scraper_mcp.services.perplexity_service.PERPLEXITY_AVAILABLE", True): + with patch( + "scraper_mcp.services.perplexity_service.Perplexity", + return_value=mock_client, + ) as mock_ctor: + with patch( + "scraper_mcp.services.perplexity_service.get_config", + side_effect=_fake_get_config(api_key="runtime-key", enabled=("sonar",)), + ): + from scraper_mcp.services.perplexity_service import PerplexityService + + service = PerplexityService() + response = await service.chat( + messages=[{"role": "user", "content": "hi"}], + model="sonar", + ) + + mock_ctor.assert_called_once_with(api_key="runtime-key") + assert response.content == "ok" class TestPerplexityTools: