Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/guardian.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ jobs:
- name: Run Guardian Review
env:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
GUARDIAN_PROVIDER: ${{ vars.GUARDIAN_PROVIDER }}
GUARDIAN_MODEL: ${{ vars.GUARDIAN_MODEL }}
run: uv run python scripts/guardian_review.py --output guardian_report.md

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ ignore_missing_imports = true
module = "google.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "mistralai.*"
ignore_missing_imports = true

[tool.interrogate]
ignore-init-method = true
ignore-init-module = true
Expand Down
32 changes: 27 additions & 5 deletions scripts/guardian_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,36 @@

from cgis.guardian.collector import ContextCollector
from cgis.guardian.core import GuardianReviewer
from cgis.guardian.providers.base import BaseProvider
from cgis.guardian.providers.gemini import GeminiProvider
from cgis.guardian.providers.mistral import MistralProvider

log = structlog.getLogger(__name__)

_DEFAULT_MODEL = "gemini-2.5-flash"
_DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"
_DEFAULT_MISTRAL_MODEL = "mistral-medium-latest"


def _build_provider() -> tuple[BaseProvider, str]:
"""Return (provider, model_name) based on GUARDIAN_PROVIDER or available API keys."""
model_override = os.environ.get("GUARDIAN_MODEL")
provider_name = os.environ.get("GUARDIAN_PROVIDER", "").lower()

if provider_name == "mistral" or (not provider_name and os.environ.get("MISTRAL_API_KEY")):
mistral_key = os.environ.get("MISTRAL_API_KEY")
if not mistral_key:
_msg = "MISTRAL_API_KEY must be set when GUARDIAN_PROVIDER=mistral"
raise RuntimeError(_msg)
model = model_override or _DEFAULT_MISTRAL_MODEL
return MistralProvider(api_key=mistral_key, model_name=model), model

gemini_key = os.environ.get("GEMINI_API_KEY")
if gemini_key:
model = model_override or _DEFAULT_GEMINI_MODEL
return GeminiProvider(api_key=gemini_key, model_name=model), model

_msg = "Set MISTRAL_API_KEY or GEMINI_API_KEY to run Guardian."
raise RuntimeError(_msg)


async def main() -> None:
Expand All @@ -27,11 +52,8 @@ async def main() -> None:
)
args = parser.parse_args()

api_key = os.environ["GEMINI_API_KEY"]
model = os.environ.get("GUARDIAN_MODEL") or _DEFAULT_MODEL
provider, model = _build_provider()
project_root = Path(__file__).parent.parent.absolute()

provider = GeminiProvider(api_key=api_key, model_name=model)
collector = ContextCollector(project_root=project_root)
reviewer = GuardianReviewer(provider=provider, context_collector=collector)

Expand Down
36 changes: 36 additions & 0 deletions src/cgis/guardian/providers/mistral.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Mistral AI LLM provider for Guardian."""

from cgis.guardian.providers.base import BaseProvider


class MistralProvider(BaseProvider):
"""Mistral AI provider. Requires: uv sync --group guardian"""

def __init__(self, api_key: str, model_name: str = "mistral-medium-latest") -> None:
"""Store credentials; mistralai is imported lazily at call time."""
self._api_key = api_key
self._model_name = model_name

async def generate_content(self, system_prompt: str, user_prompt: str) -> str:
"""Send prompts to Mistral and return the text response."""
_install_hint = "mistralai is required. Install with: uv sync --group guardian"
try:
from mistralai import Mistral # noqa: PLC0415
except ImportError as exc:
raise ImportError(_install_hint) from exc
async with Mistral(api_key=self._api_key) as client:
response = await client.chat.complete_async(
model=self._model_name,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
if not response.choices:
_msg = "Mistral returned an empty response"
raise ValueError(_msg)
content = response.choices[0].message.content
if content is None:
_msg = "Mistral returned a null message content"
raise ValueError(_msg)
return str(content)
Loading