From abcb1fefda62e81a0707a6764d9e96fae80cde5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:57:02 +0000 Subject: [PATCH 1/4] feat: add GroqProvider chat backend with full detection chain wiring --- .../chat-providers.instructions.md | 19 +- ai-projects/chat-cli/src/chat_providers.py | 244 +++++++- local.settings.json.example | 5 + shared/chat_providers.py | 7 + shared/config.py | 21 +- tests/test_groq_provider.py | 565 ++++++++++++++++++ tests/test_provider_fallback.py | 25 +- 7 files changed, 874 insertions(+), 12 deletions(-) create mode 100644 tests/test_groq_provider.py diff --git a/.github/instructions/chat-providers.instructions.md b/.github/instructions/chat-providers.instructions.md index 8847a4081..d89cf8739 100644 --- a/.github/instructions/chat-providers.instructions.md +++ b/.github/instructions/chat-providers.instructions.md @@ -10,10 +10,12 @@ Order matters — first match wins: 1. **Explicit choice** — `--provider` flag or API parameter 2. **LMStudio** — if `LMSTUDIO_BASE_URL` is set -3. **Azure OpenAI** — needs ALL 4: `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT`, `AZURE_OPENAI_API_VERSION` -4. **OpenAI** — needs `OPENAI_API_KEY` -5. **LoRA** — explicit `--provider lora` with adapter path -6. **Local echo** — zero-dependency fallback with context-aware intent recognition +3. **Ollama** — if `OLLAMA_BASE_URL` is set (or auto-detected at `http://127.0.0.1:11434/v1`) +4. **Azure OpenAI** — needs ALL 4: `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_DEPLOYMENT`, `AZURE_OPENAI_API_VERSION` +5. **OpenAI** — needs `OPENAI_API_KEY` +6. **Groq** — needs `GROQ_API_KEY`; auto-detected by probing `https://api.groq.com/openai/v1/models` +7. **LoRA** — explicit `--provider lora` with adapter path +8. **Local echo** — zero-dependency fallback with context-aware intent recognition ## Provider Contract (BaseChatProvider) @@ -27,6 +29,15 @@ class BaseChatProvider: ## Key Implementations +### GroqProvider + +- OpenAI-compatible provider for Groq cloud inference +- Requires `GROQ_API_KEY` (get one at https://console.groq.com/keys) +- Default model: `llama-3.1-8b-instant`; override with `GROQ_MODEL` or `--model` +- Default endpoint: `https://api.groq.com/openai/v1`; override with `GROQ_BASE_URL` +- Thread-safe availability cache (`_groq_availability_cache`, 30 s TTL) +- Friendly error messages for auth failures, connection errors, and model-not-found + ### LoraLocalProvider - Bridges torch + subprocess for local LoRA inference diff --git a/ai-projects/chat-cli/src/chat_providers.py b/ai-projects/chat-cli/src/chat_providers.py index fc28d817f..bd589f4e8 100644 --- a/ai-projects/chat-cli/src/chat_providers.py +++ b/ai-projects/chat-cli/src/chat_providers.py @@ -100,6 +100,15 @@ def format_quota_message(exc: Any, service_name: str = "Azure OpenAI") -> str: _ollama_cache_lock = threading.RLock() _OLLAMA_CACHE_TTL_SECONDS = 30 +# Thread-safe cache for Groq availability checks +_groq_availability_cache: dict[str, Any] = { + "available": None, + "checked_at": 0.0, + "url": None, +} +_groq_cache_lock = threading.RLock() +_GROQ_CACHE_TTL_SECONDS = 30 + # Thread-safe cache for detect_provider results to reduce repeated provider # probing and client instantiation on hot API paths (e.g., /api/ai/status, @@ -129,6 +138,8 @@ def format_quota_message(exc: Any, service_name: str = "Azure OpenAI") -> str: "qai-quantum": "quantum", "quantum_llm": "quantum", "quantum-llm": "quantum", + "groq_api": "groq", + "groq-api": "groq", } _KNOWN_PROVIDER_CHOICES: set[str] = { @@ -142,6 +153,7 @@ def format_quota_message(exc: Any, service_name: str = "Azure OpenAI") -> str: "agi", "qai", "quantum", + "groq", } @@ -1014,7 +1026,9 @@ def _complete_via_http(self, messages: list[RoleMessage], stream: bool) -> Itera if stream: def _gen() -> Generator[str, None, None]: - with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # noqa: S310 - local configurable endpoint + with urllib.request.urlopen( + req, timeout=timeout_seconds + ) as resp: # noqa: S310 - local configurable endpoint for raw_line in resp: line = raw_line.decode("utf-8", errors="replace").strip() if not line or not line.startswith("data:"): @@ -1247,6 +1261,126 @@ def gen_err() -> Generator[str, None, None]: raise +class GroqProvider(BaseChatProvider): + """Provider for the Groq cloud API (OpenAI-compatible endpoint). + + Groq provides fast inference for open models (Llama, Mixtral, Gemma, etc.) + via an OpenAI-compatible REST API. Requires the ``openai`` package and a + valid ``GROQ_API_KEY`` environment variable (or explicit *api_key* argument). + + Default endpoint: https://api.groq.com/openai/v1 + Configure via GROQ_BASE_URL environment variable. + """ + + def __init__( + self, + model: str = "llama-3.1-8b-instant", + api_key: str | None = None, + base_url: str = "https://api.groq.com/openai/v1", + temperature: float = 0.7, + max_output_tokens: int | None = None, + ): + """Initialize the Groq provider. + + Args: + model: Groq model ID (e.g. ``"llama-3.1-8b-instant"``, + ``"mixtral-8x7b-32768"``). Override with ``GROQ_MODEL``. + api_key: Groq API key. Falls back to the ``GROQ_API_KEY`` + environment variable when ``None``. + base_url: Groq OpenAI-compatible endpoint. Defaults to the + standard Groq endpoint. Override with ``GROQ_BASE_URL``. + temperature: Sampling temperature in ``[0, 2]``. + max_output_tokens: Maximum tokens to generate. + + Raises: + RuntimeError: If the ``openai`` package is not installed. + """ + if OpenAI is None: + raise RuntimeError("openai package not installed. Install 'openai' to use this provider.") + resolved_key = api_key or os.getenv("GROQ_API_KEY") + if not resolved_key: + raise RuntimeError("Groq provider requires GROQ_API_KEY to be set.") + self.client = OpenAI(base_url=base_url, api_key=resolved_key) + self.model = model + self.temperature = temperature + self.max_output_tokens = max_output_tokens + self.base_url = base_url + + def complete(self, messages: list[RoleMessage], stream: bool = True) -> Iterable[str] | str: + """Complete using Groq and surface friendly error messages for common failures.""" + try: + normalized_messages = self._normalize_messages_for_api(messages) + resp = self.client.chat.completions.create( + model=self.model, + messages=normalized_messages, + temperature=self.temperature, + max_tokens=self.max_output_tokens, + stream=stream, + ) + + if stream: + return self._handle_openai_streaming_response(resp) + else: + return self._handle_openai_non_streaming_response(resp) + except Exception as e: + error_msg = str(e).lower() + + if "connection" in error_msg or "refused" in error_msg or "timeout" in error_msg: + suggestion = ( + f"❌ Cannot connect to Groq at {self.base_url}\n\n" + f"Troubleshooting steps:\n" + f"1. Check your internet connection\n" + f"2. Verify GROQ_BASE_URL is correct (default: https://api.groq.com/openai/v1)\n" + f"3. Check https://status.groq.com for service status\n\n" + f"Set GROQ_BASE_URL environment variable if using a custom endpoint." + ) + if stream: + + def gen_conn_err() -> Generator[str, None, None]: + yield suggestion + + return gen_conn_err() + return suggestion + + if "invalid_api_key" in error_msg or "authentication" in error_msg or "401" in error_msg: + suggestion = ( + "❌ Groq authentication failed.\n\n" + "Troubleshooting steps:\n" + "1. Check that GROQ_API_KEY is set and valid\n" + "2. Get a key at https://console.groq.com/keys\n" + "3. Re-run with --provider groq\n\n" + "Example:\n" + "export GROQ_API_KEY=''" + ) + if stream: + + def gen_auth_err() -> Generator[str, None, None]: + yield suggestion + + return gen_auth_err() + return suggestion + + if "model" in error_msg and ("not found" in error_msg or "does not exist" in error_msg): + suggestion = ( + f"❌ Model '{self.model}' not found on Groq.\n\n" + f"Troubleshooting steps:\n" + f"1. Check available models at https://console.groq.com/docs/models\n" + f"2. Use --model flag to specify a valid model name\n" + f"3. Set GROQ_MODEL environment variable\n\n" + f"Popular models: llama-3.1-8b-instant, llama-3.3-70b-versatile, mixtral-8x7b-32768" + ) + if stream: + + def gen_model_err() -> Generator[str, None, None]: + yield suggestion + + return gen_model_err() + return suggestion + + # Re-raise unexpected errors + raise + + class AzureOpenAIProvider(BaseChatProvider): """Provider for Azure-hosted OpenAI deployments. @@ -1506,6 +1640,69 @@ def _check_ollama_available(server_url: str) -> bool: return is_available +def _check_groq_available(server_url: str) -> bool: + """Check if the Groq API is reachable with the configured API key. + + Uses a thread-safe cache to avoid repeated HTTP requests within the TTL period. + + Args: + server_url: Base URL for Groq OpenAI-compatible API (e.g., + ``"https://api.groq.com/openai/v1"``). + + Returns: + True if Groq is reachable and the API key is accepted, False otherwise. + """ + # Check cache under lock + with _groq_cache_lock: + current_time = time.time() + if ( + _groq_availability_cache["available"] is not None + and _groq_availability_cache["url"] == server_url + and (current_time - _groq_availability_cache["checked_at"]) < _GROQ_CACHE_TTL_SECONDS + ): + return _groq_availability_cache["available"] + + # Perform HTTP check outside lock to avoid blocking other threads + is_available = False + groq_api_key = os.getenv("GROQ_API_KEY") + if not groq_api_key: + # No key configured — Groq cannot be available without authentication + with _groq_cache_lock: + _groq_availability_cache["available"] = False + _groq_availability_cache["checked_at"] = time.time() + _groq_availability_cache["url"] = server_url + return False + + try: + import urllib.error + import urllib.request + + models_url = server_url.rstrip("/") + "/models" + headers = {"User-Agent": "QAI", "Authorization": "Bearer " + groq_api_key} + request = urllib.request.Request(models_url, headers=headers) + urllib.request.urlopen(request, timeout=3) + is_available = True + except Exception as exc: + # Treat 401/403 as "available but wrong key" — key presence already verified above + try: + import urllib.error as _ue + + if isinstance(exc, _ue.HTTPError) and exc.code in (401, 403): + is_available = True + else: + is_available = False + except Exception: + is_available = False + + # Update cache under lock + with _groq_cache_lock: + _groq_availability_cache["available"] = is_available + _groq_availability_cache["checked_at"] = time.time() + _groq_availability_cache["url"] = server_url + + return is_available + + def _get_provider_detect_cache_ttl_seconds() -> float: """Resolve provider-detection cache TTL from env with safe bounds.""" return _get_bounded_timeout_env( @@ -1541,6 +1738,9 @@ def _build_provider_detect_cache_key( os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview"), bool(os.getenv("OPENAI_API_KEY")), os.getenv("OPENAI_MODEL", "gpt-4o-mini"), + bool(os.getenv("GROQ_API_KEY")), + os.getenv("GROQ_MODEL", "llama-3.1-8b-instant"), + os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1"), ) @@ -1613,6 +1813,7 @@ def detect_provider( * ``"azure"`` → :class:`AzureOpenAIProvider` (needs ``AZURE_OPENAI_API_KEY``, ``AZURE_OPENAI_ENDPOINT``, ``AZURE_OPENAI_DEPLOYMENT``). * ``"openai"`` → :class:`OpenAIProvider` (needs ``OPENAI_API_KEY``). + * ``"groq"`` → :class:`GroqProvider` (needs ``GROQ_API_KEY``). * ``"local"`` → probes LM Studio then Ollama; falls back to :class:`LocalEchoProvider`. * ``"local_echo"`` / ``"local-echo"`` → :class:`LocalEchoProvider` directly. @@ -1623,11 +1824,12 @@ def detect_provider( * ``"lora"`` → :class:`LoraLocalProvider`; requires *model_override* path. 2. **Auto mode** (``explicit=None`` or ``"auto"``) — probes in order: - LM Studio → Ollama → Azure OpenAI → OpenAI → :class:`LocalEchoProvider`. + LM Studio → Ollama → Azure OpenAI → OpenAI → Groq → :class:`LocalEchoProvider`. - Results for the ``auto``, ``local``, ``lmstudio``, ``ollama``, ``azure``, and - ``openai`` choices are cached for :data:`_PROVIDER_DETECT_CACHE_TTL_SECONDS` - seconds (default 5 s) to avoid repeated availability probes on hot paths. + Results for the ``auto``, ``local``, ``lmstudio``, ``ollama``, ``azure``, + ``openai``, and ``groq`` choices are cached for + :data:`_PROVIDER_DETECT_CACHE_TTL_SECONDS` seconds (default 5 s) to avoid + repeated availability probes on hot paths. Cache TTL can be tuned with ``QAI_PROVIDER_DETECT_CACHE_TTL``. Args: @@ -1659,7 +1861,7 @@ def detect_provider( # Cache only non-special providers. AGI/Quantum/LoRA can be stateful or # model-path specific and are intentionally resolved fresh. cache_key: tuple[Any, ...] | None = None - if provider_choice in {"auto", "local", "lmstudio", "ollama", "azure", "openai"}: + if provider_choice in {"auto", "local", "lmstudio", "ollama", "azure", "openai", "groq"}: cache_key = _build_provider_detect_cache_key(provider_choice, model_override, temperature, max_output_tokens) cached = _get_cached_provider_detection(cache_key) if cached is not None: @@ -1673,6 +1875,11 @@ def detect_provider( ollama_base_url = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434/v1") ollama_model_name = os.getenv("OLLAMA_MODEL", "llama3.2") + # Groq config + groq_api_key = os.getenv("GROQ_API_KEY") + groq_model_name = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant") + groq_base_url = os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1") + # AGI config - advanced reasoning capabilities if provider_choice == "agi": try: @@ -1816,6 +2023,19 @@ def detect_provider( ) return _cache_provider_result(cache_key, provider, ProviderChoice(name="openai", model=selected_model)) + if provider_choice == "groq": + if not groq_api_key: + raise RuntimeError("Groq selected but GROQ_API_KEY is not set.") + selected_model = model_override or groq_model_name + provider = GroqProvider( + model=selected_model, + api_key=groq_api_key, + base_url=groq_base_url, + temperature=temperature_setting, + max_output_tokens=max_output_tokens, + ) + return _cache_provider_result(cache_key, provider, ProviderChoice(name="groq", model=selected_model)) + if provider_choice == "local": if force_local_echo: selected_model = model_override or "local-echo" @@ -1892,6 +2112,18 @@ def detect_provider( ) return _cache_provider_result(cache_key, provider, ProviderChoice(name="openai", model=selected_model)) + # Check Groq after OpenAI in auto mode + if groq_api_key and _check_groq_available(groq_base_url): + selected_model = model_override or groq_model_name + provider = GroqProvider( + model=selected_model, + api_key=groq_api_key, + base_url=groq_base_url, + temperature=temperature_setting, + max_output_tokens=max_output_tokens, + ) + return _cache_provider_result(cache_key, provider, ProviderChoice(name="groq", model=selected_model)) + # Fallback to local echo provider selected_model = model_override or "local-echo" provider = LocalEchoProvider() diff --git a/local.settings.json.example b/local.settings.json.example index 4ae2ee10b..59d2f9f0e 100644 --- a/local.settings.json.example +++ b/local.settings.json.example @@ -16,6 +16,11 @@ "AZURE_OPENAI_DEPLOYMENT": "gpt-4.1", "AZURE_OPENAI_API_VERSION": "2024-08-01-preview", "OPENAI_API_KEY": "", + "# Optional: Groq cloud API - get a free key at https://console.groq.com": "", + "# Supports fast open models: llama-3.1-8b-instant, mixtral-8x7b-32768, gemma2-9b-it": "", + "GROQ_API_KEY": "", + "GROQ_MODEL": "llama-3.1-8b-instant", + "GROQ_BASE_URL": "", "# Optional: Ollama local AI - install from https://ollama.ai and run 'ollama serve'": "", "# Then pull a model: ollama pull llama3.2 (or mistral, codellama, phi3, etc.)": "", "# Leave OLLAMA_BASE_URL blank to use default http://127.0.0.1:11434/v1 (auto-detected)": "", diff --git a/shared/chat_providers.py b/shared/chat_providers.py index 3c2211d91..fb257bf17 100644 --- a/shared/chat_providers.py +++ b/shared/chat_providers.py @@ -61,6 +61,13 @@ except AttributeError: pass +# Conditionally export GroqProvider if available +try: + GroqProvider = _canonical_module.GroqProvider + __all__.append("GroqProvider") +except AttributeError: + pass + # Conditionally export AGI provider using the same dynamic import pattern try: _agi_path = Path(__file__).resolve().parent.parent / "ai-projects" / "chat-cli" / "src" / "agi_provider.py" diff --git a/shared/config.py b/shared/config.py index 829e78947..4a8f9d93d 100644 --- a/shared/config.py +++ b/shared/config.py @@ -19,7 +19,7 @@ from typing import Annotated _LOG = logging.getLogger(__name__) -_DEFAULT_PROVIDER_PRIORITY = ["lmstudio", "ollama", "azure", "openai", "local"] +_DEFAULT_PROVIDER_PRIORITY = ["lmstudio", "ollama", "azure", "openai", "groq", "local"] def _normalize_provider_priority(value: object) -> list[str]: @@ -114,6 +114,11 @@ class Settings(BaseSettings): # type: ignore[misc] # ------------------------------------------------------------------ openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY") + # ------------------------------------------------------------------ + # Groq + # ------------------------------------------------------------------ + groq_api_key: str | None = Field(default=None, alias="GROQ_API_KEY") + # ------------------------------------------------------------------ # LM Studio / local inference # ------------------------------------------------------------------ @@ -222,6 +227,11 @@ def openai_ready(self) -> bool: """Return True when the OpenAI key is set.""" return bool(self.openai_api_key) + @property + def groq_ready(self) -> bool: + """Return True when the Groq API key is set.""" + return bool(self.groq_api_key) + @property def lmstudio_ready(self) -> bool: """Return True when an LM Studio URL is configured.""" @@ -239,6 +249,7 @@ def active_provider(self) -> str: "ollama": self.ollama_ready, "azure": self.azure_openai_ready, "openai": self.openai_ready, + "groq": self.groq_ready, } for name in self.provider_chain(): if checks.get(name, False): @@ -256,6 +267,7 @@ def summary(self) -> dict: "provider_chain": self.provider_chain(), "azure_openai_ready": self.azure_openai_ready, "openai_ready": self.openai_ready, + "groq_ready": self.groq_ready, "lmstudio_ready": self.lmstudio_ready, "ollama_ready": self.ollama_ready, "db_enabled": bool(self.db_connection_string), @@ -277,6 +289,7 @@ def __init__(self) -> None: self.azure_openai_deployment = os.environ.get("AZURE_OPENAI_DEPLOYMENT") self.azure_openai_api_version = os.environ.get("AZURE_OPENAI_API_VERSION", "2024-02-01") self.openai_api_key = os.environ.get("OPENAI_API_KEY") + self.groq_api_key = os.environ.get("GROQ_API_KEY") self.lmstudio_base_url = os.environ.get("LMSTUDIO_BASE_URL") self.ollama_base_url = os.environ.get("OLLAMA_BASE_URL") self.provider_priority = _normalize_provider_priority( @@ -321,6 +334,10 @@ def azure_openai_ready(self) -> bool: def openai_ready(self) -> bool: return bool(self.openai_api_key) + @property + def groq_ready(self) -> bool: + return bool(self.groq_api_key) + @property def lmstudio_ready(self) -> bool: return bool(self.lmstudio_base_url and str(self.lmstudio_base_url).strip()) @@ -335,6 +352,7 @@ def active_provider(self) -> str: "ollama": self.ollama_ready, "azure": self.azure_openai_ready, "openai": self.openai_ready, + "groq": self.groq_ready, } for name in self.provider_priority: if checks.get(name, False): @@ -350,6 +368,7 @@ def summary(self) -> dict: "provider_chain": self.provider_chain(), "azure_openai_ready": self.azure_openai_ready, "openai_ready": self.openai_ready, + "groq_ready": self.groq_ready, "lmstudio_ready": self.lmstudio_ready, "ollama_ready": self.ollama_ready, "db_enabled": bool(self.db_connection_string), diff --git a/tests/test_groq_provider.py b/tests/test_groq_provider.py new file mode 100644 index 000000000..916f36822 --- /dev/null +++ b/tests/test_groq_provider.py @@ -0,0 +1,565 @@ +"""Tests for GroqProvider and Groq-related detection logic. + +Covers: + - GroqProvider instantiation (with and without openai package) + - Streaming and non-streaming complete() paths + - Friendly error messages for connection, auth, and model-not-found errors + - _check_groq_available caching behaviour + - detect_provider with explicit 'groq' selection + - detect_provider auto-detection when Groq key is set and endpoint is reachable + - GROQ_API_KEY / GROQ_MODEL / GROQ_BASE_URL env-var wiring + - Alias resolution (groq-api, groq_api) + - Cache key includes Groq env vars +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure chat_providers is importable from the source tree +_CHAT_SRC = Path(__file__).parent.parent / "ai-projects" / "chat-cli" / "src" +if str(_CHAT_SRC) not in sys.path: + sys.path.insert(0, str(_CHAT_SRC)) + +from chat_providers import ( + GroqProvider, + LocalEchoProvider, + _build_provider_detect_cache_key, + _check_groq_available, + _groq_availability_cache, + _groq_cache_lock, + _provider_detection_cache, + _provider_detection_cache_lock, + detect_provider, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _reset_groq_cache() -> None: + """Reset the Groq availability cache between tests.""" + with _groq_cache_lock: + _groq_availability_cache["available"] = None + _groq_availability_cache["checked_at"] = 0.0 + _groq_availability_cache["url"] = None + + +def _reset_detect_provider_cache() -> None: + """Reset detect_provider result cache between tests.""" + with _provider_detection_cache_lock: + _provider_detection_cache.clear() + + +# --------------------------------------------------------------------------- +# GroqProvider — basic construction +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_groq_provider_requires_openai_package(monkeypatch): + """GroqProvider raises RuntimeError when openai is not installed.""" + import chat_providers as cp + + original = cp.OpenAI + try: + cp.OpenAI = None # simulate missing package + with pytest.raises(RuntimeError, match="openai package not installed"): + GroqProvider(api_key="test-key") + finally: + cp.OpenAI = original + + +@pytest.mark.unit +def test_groq_provider_requires_api_key(monkeypatch): + """GroqProvider raises RuntimeError when no API key is available.""" + monkeypatch.delenv("GROQ_API_KEY", raising=False) + with patch("chat_providers.OpenAI") as mock_cls: + mock_cls.return_value = MagicMock() + with pytest.raises(RuntimeError, match="GROQ_API_KEY"): + GroqProvider() + + +@pytest.mark.unit +def test_groq_provider_defaults(monkeypatch): + """GroqProvider picks up default base_url, model, and temperature.""" + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + p = GroqProvider(api_key="gsk-test") + assert p.base_url == "https://api.groq.com/openai/v1" + assert p.model == "llama-3.1-8b-instant" + assert p.temperature == 0.7 + + +@pytest.mark.unit +def test_groq_provider_custom_params(): + """GroqProvider accepts custom model, base_url, and temperature.""" + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + p = GroqProvider( + model="mixtral-8x7b-32768", + api_key="gsk-test", + base_url="https://custom.groq.example/v1", + temperature=0.3, + max_output_tokens=512, + ) + assert p.model == "mixtral-8x7b-32768" + assert p.base_url == "https://custom.groq.example/v1" + assert p.temperature == 0.3 + assert p.max_output_tokens == 512 + + +@pytest.mark.unit +def test_groq_provider_reads_api_key_from_env(monkeypatch): + """GroqProvider reads GROQ_API_KEY from env when no explicit key given.""" + monkeypatch.setenv("GROQ_API_KEY", "env-key-123") + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + p = GroqProvider() + assert p is not None + + +# --------------------------------------------------------------------------- +# GroqProvider — complete() non-streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_groq_provider_complete_non_stream(): + """complete(stream=False) returns the full response string.""" + mock_client = MagicMock() + mock_response = MagicMock() + mock_response.choices[0].message.content = "Hello from Groq!" + mock_client.chat.completions.create.return_value = mock_response + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(model="llama-3.1-8b-instant", api_key="gsk-test") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=False) + assert result == "Hello from Groq!" + call_kwargs = mock_client.chat.completions.create.call_args[1] + assert call_kwargs["stream"] is False + assert call_kwargs["model"] == "llama-3.1-8b-instant" + + +# --------------------------------------------------------------------------- +# GroqProvider — complete() streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_groq_provider_complete_stream(): + """complete(stream=True) yields text chunks from streaming response.""" + + def _mock_chunks(): + for word in ["Hello", " from", " Groq"]: + chunk = MagicMock() + chunk.choices[0].delta.content = word + yield chunk + + mock_client = MagicMock() + mock_client.chat.completions.create.return_value = _mock_chunks() + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(model="llama-3.1-8b-instant", api_key="gsk-test") + p.client = mock_client + + chunks = list(p.complete([{"role": "user", "content": "hi"}], stream=True)) + assert chunks == ["Hello", " from", " Groq"] + + +# --------------------------------------------------------------------------- +# GroqProvider — friendly error messages +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_groq_provider_connection_error_stream(): + """Friendly message yielded when Groq API is unreachable (stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = ConnectionRefusedError("Connection refused") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(api_key="gsk-test") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=True) + text = "".join(result) + assert "Cannot connect to Groq" in text + + +@pytest.mark.unit +def test_groq_provider_connection_error_non_stream(): + """Friendly message returned when Groq API is unreachable (non-stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = ConnectionRefusedError("Connection refused") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(api_key="gsk-test") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=False) + assert isinstance(result, str) + assert "Cannot connect to Groq" in result + + +@pytest.mark.unit +def test_groq_provider_auth_error_stream(): + """Friendly message yielded when Groq auth fails (stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("invalid_api_key: authentication failed") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(api_key="bad-key") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=True) + text = "".join(result) + assert "authentication" in text.lower() or "GROQ_API_KEY" in text + + +@pytest.mark.unit +def test_groq_provider_auth_error_non_stream(): + """Friendly message returned when Groq auth fails (non-stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("invalid_api_key: authentication failed") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(api_key="bad-key") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=False) + assert isinstance(result, str) + assert "GROQ_API_KEY" in result or "authentication" in result.lower() + + +@pytest.mark.unit +def test_groq_provider_model_not_found_stream(): + """Friendly message yielded when the requested Groq model is not found (stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("model 'bad-model' not found") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(model="bad-model", api_key="gsk-test") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=True) + text = "".join(result) + assert "not found on Groq" in text + + +@pytest.mark.unit +def test_groq_provider_model_not_found_non_stream(): + """Friendly message returned when the requested Groq model is not found (non-stream).""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = Exception("model 'bad-model' not found") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(model="bad-model", api_key="gsk-test") + p.client = mock_client + + result = p.complete([{"role": "user", "content": "hi"}], stream=False) + assert isinstance(result, str) + assert "not found on Groq" in result + + +@pytest.mark.unit +def test_groq_provider_unexpected_error_raises(): + """Unexpected exceptions (not connection/auth/model errors) are re-raised.""" + mock_client = MagicMock() + mock_client.chat.completions.create.side_effect = ValueError("unexpected error") + + with patch("chat_providers.OpenAI", return_value=mock_client): + p = GroqProvider(api_key="gsk-test") + p.client = mock_client + + with pytest.raises(ValueError, match="unexpected error"): + p.complete([{"role": "user", "content": "hi"}], stream=False) + + +# --------------------------------------------------------------------------- +# _check_groq_available — caching behaviour +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_check_groq_available_false_when_no_key(monkeypatch): + """Returns False immediately when GROQ_API_KEY is not set.""" + _reset_groq_cache() + monkeypatch.delenv("GROQ_API_KEY", raising=False) + + with patch("urllib.request.urlopen") as mock_urlopen: + result = _check_groq_available("https://api.groq.com/openai/v1") + + assert result is False + mock_urlopen.assert_not_called() + + +@pytest.mark.unit +def test_check_groq_available_true_when_reachable(monkeypatch): + """Returns True when the Groq models endpoint responds.""" + _reset_groq_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + + with patch("urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = MagicMock() + result = _check_groq_available("https://api.groq.com/openai/v1") + + assert result is True + + +@pytest.mark.unit +def test_check_groq_available_false_when_unreachable(monkeypatch): + """Returns False when the Groq endpoint raises an exception.""" + _reset_groq_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + import urllib.error + + with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("no route")): + result = _check_groq_available("https://api.groq.com/openai/v1") + + assert result is False + + +@pytest.mark.unit +def test_check_groq_available_uses_cache(monkeypatch): + """Second call within TTL does not make a new HTTP request.""" + _reset_groq_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + + with patch("urllib.request.urlopen") as mock_urlopen: + mock_urlopen.return_value = MagicMock() + first = _check_groq_available("https://api.groq.com/openai/v1") + second = _check_groq_available("https://api.groq.com/openai/v1") + + assert mock_urlopen.call_count == 1 + assert first is True + assert second is True + + +@pytest.mark.unit +def test_check_groq_available_cache_different_url(monkeypatch): + """Different URL invalidates cache and triggers a new HTTP check.""" + _reset_groq_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + + call_count = 0 + + def urlopen_side_effect(req, timeout=None): + nonlocal call_count + call_count += 1 + if "api.groq.com" in req.full_url: + return MagicMock() + import urllib.error + + raise urllib.error.URLError("refused") + + with patch("urllib.request.urlopen", side_effect=urlopen_side_effect): + r1 = _check_groq_available("https://api.groq.com/openai/v1") + _reset_groq_cache() + r2 = _check_groq_available("https://custom.example.com/openai/v1") + + assert r1 is True + assert r2 is False + + +# --------------------------------------------------------------------------- +# detect_provider — explicit 'groq' selection +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_detect_provider_explicit_groq(monkeypatch): + """detect_provider('groq') returns GroqProvider with GROQ_MODEL.""" + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.setenv("GROQ_MODEL", "mixtral-8x7b-32768") + + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="groq") + + assert choice.name == "groq" + assert choice.model == "mixtral-8x7b-32768" + assert isinstance(provider, GroqProvider) + + +@pytest.mark.unit +def test_detect_provider_explicit_groq_model_override(monkeypatch): + """detect_provider('groq', model_override=...) uses override model.""" + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.delenv("GROQ_MODEL", raising=False) + + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="groq", model_override="gemma2-9b-it") + + assert choice.name == "groq" + assert choice.model == "gemma2-9b-it" + assert provider.model == "gemma2-9b-it" + + +@pytest.mark.unit +def test_detect_provider_explicit_groq_default_model(monkeypatch): + """detect_provider('groq') falls back to default model when GROQ_MODEL unset.""" + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.delenv("GROQ_MODEL", raising=False) + + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="groq") + + assert choice.name == "groq" + assert choice.model == "llama-3.1-8b-instant" + + +@pytest.mark.unit +def test_detect_provider_explicit_groq_without_key_raises(monkeypatch): + """detect_provider('groq') raises RuntimeError when GROQ_API_KEY is missing.""" + _reset_detect_provider_cache() + monkeypatch.delenv("GROQ_API_KEY", raising=False) + + with pytest.raises(RuntimeError, match="GROQ_API_KEY"): + detect_provider(explicit="groq") + + +# --------------------------------------------------------------------------- +# detect_provider — alias resolution +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_detect_provider_alias_groq_api(monkeypatch): + """'groq-api' alias resolves to GroqProvider.""" + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="groq-api") + + assert choice.name == "groq" + assert isinstance(provider, GroqProvider) + + +@pytest.mark.unit +def test_detect_provider_alias_groq_api_underscore(monkeypatch): + """'groq_api' alias resolves to GroqProvider.""" + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + + with patch("chat_providers.OpenAI") as mock_openai_cls: + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="groq_api") + + assert choice.name == "groq" + assert isinstance(provider, GroqProvider) + + +# --------------------------------------------------------------------------- +# detect_provider — auto-detection picks Groq when reachable +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_detect_provider_auto_picks_groq_when_reachable(monkeypatch): + """Auto-detect selects Groq when it is reachable and other providers are not.""" + _reset_groq_cache() + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.setenv("GROQ_MODEL", "llama-3.1-8b-instant") + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + def fake_check_lm(url): + return False + + def fake_check_ollama(url): + return False + + def fake_check_groq(url): + return True + + with ( + patch("chat_providers._check_lm_studio_available", side_effect=fake_check_lm), + patch("chat_providers._check_ollama_available", side_effect=fake_check_ollama), + patch("chat_providers._check_groq_available", side_effect=fake_check_groq), + patch("chat_providers.OpenAI") as mock_openai_cls, + ): + mock_openai_cls.return_value = MagicMock() + provider, choice = detect_provider(explicit="auto") + + assert choice.name == "groq" + assert isinstance(provider, GroqProvider) + + +@pytest.mark.unit +def test_detect_provider_groq_not_picked_when_unreachable(monkeypatch): + """Auto-detect falls through to local echo when Groq is unreachable.""" + _reset_groq_cache() + _reset_detect_provider_cache() + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.delenv("AZURE_OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + with ( + patch("chat_providers._check_lm_studio_available", return_value=False), + patch("chat_providers._check_ollama_available", return_value=False), + patch("chat_providers._check_groq_available", return_value=False), + ): + provider, choice = detect_provider(explicit="auto") + + assert choice.name == "local" + assert isinstance(provider, LocalEchoProvider) + + +# --------------------------------------------------------------------------- +# Cache key includes Groq env vars +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_cache_key_includes_groq_api_key_presence(monkeypatch): + """Cache key changes when GROQ_API_KEY is set vs. unset.""" + monkeypatch.delenv("GROQ_API_KEY", raising=False) + key_without = _build_provider_detect_cache_key("auto", None, None, None) + + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + key_with = _build_provider_detect_cache_key("auto", None, None, None) + + assert key_without != key_with + + +@pytest.mark.unit +def test_cache_key_includes_groq_model(monkeypatch): + """Cache key changes when GROQ_MODEL changes.""" + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.setenv("GROQ_MODEL", "llama-3.1-8b-instant") + key_a = _build_provider_detect_cache_key("groq", None, None, None) + + monkeypatch.setenv("GROQ_MODEL", "mixtral-8x7b-32768") + key_b = _build_provider_detect_cache_key("groq", None, None, None) + + assert key_a != key_b + + +@pytest.mark.unit +def test_cache_key_includes_groq_base_url(monkeypatch): + """Cache key changes when GROQ_BASE_URL changes.""" + monkeypatch.setenv("GROQ_API_KEY", "gsk-test") + monkeypatch.delenv("GROQ_BASE_URL", raising=False) + key_default = _build_provider_detect_cache_key("groq", None, None, None) + + monkeypatch.setenv("GROQ_BASE_URL", "https://custom.groq.example/v1") + key_custom = _build_provider_detect_cache_key("groq", None, None, None) + + assert key_default != key_custom diff --git a/tests/test_provider_fallback.py b/tests/test_provider_fallback.py index bb4c5a12c..55f779cab 100644 --- a/tests/test_provider_fallback.py +++ b/tests/test_provider_fallback.py @@ -34,7 +34,7 @@ def _clean(): class TestProviderPriorityChain: - """Provider detection should follow: lmstudio > ollama > azure > openai > local.""" + """Provider detection should follow: lmstudio > ollama > azure > openai > groq > local.""" def _clean_env(self) -> dict: """Return env with all provider keys stripped out.""" @@ -45,6 +45,7 @@ def _clean_env(self) -> dict: "OPENAI_API_KEY", "LMSTUDIO_BASE_URL", "OLLAMA_BASE_URL", + "GROQ_API_KEY", } return {k: v for k, v in os.environ.items() if k not in strip} @@ -116,6 +117,24 @@ def test_azure_requires_all_four_vars(self): # api_version has a default, so azure should be ready when key/endpoint/deployment set assert s.azure_openai_ready is True + def test_groq_detected_when_key_is_set(self): + """Settings.active_provider() returns 'groq' when only GROQ_API_KEY is set.""" + env = self._clean_env() + env["GROQ_API_KEY"] = "gsk-test" + with patch.dict(os.environ, env, clear=True): + s = Settings() + assert s.groq_ready is True + assert s.active_provider() == "groq" + + def test_groq_not_selected_when_openai_present(self): + """OpenAI takes priority over Groq in the default chain.""" + env = self._clean_env() + env["OPENAI_API_KEY"] = "sk-test" + env["GROQ_API_KEY"] = "gsk-test" + with patch.dict(os.environ, env, clear=True): + s = Settings() + assert s.active_provider() == "openai" + # --------------------------------------------------------------------------- # safe_import helper @@ -215,3 +234,7 @@ def test_lmstudio_ready_is_bool(self): def test_ollama_ready_is_bool(self): s = Settings() assert isinstance(s.ollama_ready, bool) + + def test_groq_ready_is_bool(self): + s = Settings() + assert isinstance(s.groq_ready, bool) From eebb6f3cc8f8e1291f3a050523298fdf6c31f1da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:00:29 +0000 Subject: [PATCH 2/4] fix: lint errors in GroqProvider (f-strings, URL check, spelling) --- tests/test_groq_provider.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_groq_provider.py b/tests/test_groq_provider.py index 916f36822..6900e6b35 100644 --- a/tests/test_groq_provider.py +++ b/tests/test_groq_provider.py @@ -4,7 +4,7 @@ - GroqProvider instantiation (with and without openai package) - Streaming and non-streaming complete() paths - Friendly error messages for connection, auth, and model-not-found errors - - _check_groq_available caching behaviour + - _check_groq_available caching behavior - detect_provider with explicit 'groq' selection - detect_provider auto-detection when Groq key is set and endpoint is reachable - GROQ_API_KEY / GROQ_MODEL / GROQ_BASE_URL env-var wiring @@ -355,7 +355,9 @@ def test_check_groq_available_cache_different_url(monkeypatch): def urlopen_side_effect(req, timeout=None): nonlocal call_count call_count += 1 - if "api.groq.com" in req.full_url: + from urllib.parse import urlparse + + if urlparse(req.full_url).hostname == "api.groq.com": return MagicMock() import urllib.error From 3a02ec8d5adef7ff23179daec1f5bbbeedb647f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:03:25 +0000 Subject: [PATCH 3/4] fix: _check_groq_available returns False for all errors including 401/403 --- ai-projects/chat-cli/src/chat_providers.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/ai-projects/chat-cli/src/chat_providers.py b/ai-projects/chat-cli/src/chat_providers.py index bd589f4e8..6e91dd5f1 100644 --- a/ai-projects/chat-cli/src/chat_providers.py +++ b/ai-projects/chat-cli/src/chat_providers.py @@ -1682,17 +1682,10 @@ def _check_groq_available(server_url: str) -> bool: request = urllib.request.Request(models_url, headers=headers) urllib.request.urlopen(request, timeout=3) is_available = True - except Exception as exc: - # Treat 401/403 as "available but wrong key" — key presence already verified above - try: - import urllib.error as _ue - - if isinstance(exc, _ue.HTTPError) and exc.code in (401, 403): - is_available = True - else: - is_available = False - except Exception: - is_available = False + except Exception: + # Any error (connection refused, 401/403 invalid key, timeout) means + # Groq is not available for auto-detection purposes. + is_available = False # Update cache under lock with _groq_cache_lock: From d8276a96fab287099990c50b479fe39e0cb51aac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:19:20 +0000 Subject: [PATCH 4/4] style: apply automated autofixes (ruff / prettier / clang-format) [CodeQL Advanced] --- .github/DEFAULT_GITHUB_AUTOMATION.md | 8 +- .../check-auto-merge-eligibility/action.yml | 196 +++++++++--------- ai-projects/chat-cli/src/chat_providers.py | 4 +- tests/test_auto_fix_workflow.py | 4 +- tests/test_auto_merge_workflow.py | 125 +++-------- 5 files changed, 136 insertions(+), 201 deletions(-) diff --git a/.github/DEFAULT_GITHUB_AUTOMATION.md b/.github/DEFAULT_GITHUB_AUTOMATION.md index fb300a878..058a6fb84 100644 --- a/.github/DEFAULT_GITHUB_AUTOMATION.md +++ b/.github/DEFAULT_GITHUB_AUTOMATION.md @@ -37,10 +37,10 @@ This repository uses a default GitHub automation baseline for quality, safety, a - Bot-authored PRs can be automatically approved when `AUTO_MERGE_BOT_APPROVE=true` (repository variable) and `AUTO_MERGE_APPROVE_TOKEN` (PAT secret) are configured. - Required labels (create manually or via the CLI): - ```bash - gh label create auto-merge --color 0075ca --description "Squash-merge when all CI gates pass" - gh label create autofix --color e4e669 --description "Auto-merge for automated fix PRs" - ``` + ```bash + gh label create auto-merge --color 0075ca --description "Squash-merge when all CI gates pass" + gh label create autofix --color e4e669 --description "Auto-merge for automated fix PRs" + ``` - Human-authored PRs always require at least one human approval regardless of labels. 9. **Dependabot auto-merge** diff --git a/.github/actions/check-auto-merge-eligibility/action.yml b/.github/actions/check-auto-merge-eligibility/action.yml index 32e66ede8..cff1717ef 100644 --- a/.github/actions/check-auto-merge-eligibility/action.yml +++ b/.github/actions/check-auto-merge-eligibility/action.yml @@ -1,118 +1,118 @@ name: Check Auto-Merge Eligibility description: > - Validates all eligibility criteria for automatic PR merging and outputs - whether the PR is eligible together with a human-readable reason. - Checks: not draft, targets main, not a fork, has auto-merge/autofix label, - not blocked/conflicted, no CHANGES_REQUESTED reviews, has an APPROVED review. + Validates all eligibility criteria for automatic PR merging and outputs + whether the PR is eligible together with a human-readable reason. + Checks: not draft, targets main, not a fork, has auto-merge/autofix label, + not blocked/conflicted, no CHANGES_REQUESTED reviews, has an APPROVED review. inputs: - pr-number: - description: Pull request number to evaluate - required: true - github-token: - description: > - GitHub token with pull-requests:read and checks:read scope. - Defaults to the built-in GITHUB_TOKEN. - required: false - default: ${{ github.token }} + pr-number: + description: Pull request number to evaluate + required: true + github-token: + description: > + GitHub token with pull-requests:read and checks:read scope. + Defaults to the built-in GITHUB_TOKEN. + required: false + default: ${{ github.token }} outputs: - eligible: - description: "'true' if the PR meets all auto-merge criteria, 'false' otherwise" - value: ${{ steps.check.outputs.eligible }} - reason: - description: > - Human-readable explanation of the eligibility decision. - When eligible=true this is a success message; otherwise it lists failures. - value: ${{ steps.check.outputs.reason }} + eligible: + description: "'true' if the PR meets all auto-merge criteria, 'false' otherwise" + value: ${{ steps.check.outputs.eligible }} + reason: + description: > + Human-readable explanation of the eligibility decision. + When eligible=true this is a success message; otherwise it lists failures. + value: ${{ steps.check.outputs.reason }} runs: - using: composite - steps: - - name: Evaluate PR eligibility - id: check - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - PR_NUMBER: ${{ inputs.pr-number }} - run: | - set -euo pipefail + using: composite + steps: + - name: Evaluate PR eligibility + id: check + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + PR_NUMBER: ${{ inputs.pr-number }} + run: | + set -euo pipefail - # --------------------------------------------------------------------------- - # Fetch PR metadata via the GitHub CLI. - # --------------------------------------------------------------------------- - pr_json="$(gh pr view "$PR_NUMBER" \ - --json number,isDraft,baseRefName,headRepository,labels,mergeStateStatus,reviewDecision,state \ - 2>&1)" || { - # Sanitize raw gh output: strip ANSI escape codes, keep first line, - # truncate using bash substring (character-aware, avoids splitting - # multi-byte UTF-8 sequences that cut -c can mishandle on some systems). - first_line="$(printf '%s' "${pr_json}" | head -n1 | sed 's/\x1b\[[0-9;]*m//g')" - sanitized="${first_line:0:120}" - echo "eligible=false" >> "$GITHUB_OUTPUT" - printf 'reason=Failed to fetch PR data: %s\n' "${sanitized}" >> "$GITHUB_OUTPUT" - exit 0 - } + # --------------------------------------------------------------------------- + # Fetch PR metadata via the GitHub CLI. + # --------------------------------------------------------------------------- + pr_json="$(gh pr view "$PR_NUMBER" \ + --json number,isDraft,baseRefName,headRepository,labels,mergeStateStatus,reviewDecision,state \ + 2>&1)" || { + # Sanitize raw gh output: strip ANSI escape codes, keep first line, + # truncate using bash substring (character-aware, avoids splitting + # multi-byte UTF-8 sequences that cut -c can mishandle on some systems). + first_line="$(printf '%s' "${pr_json}" | head -n1 | sed 's/\x1b\[[0-9;]*m//g')" + sanitized="${first_line:0:120}" + echo "eligible=false" >> "$GITHUB_OUTPUT" + printf 'reason=Failed to fetch PR data: %s\n' "${sanitized}" >> "$GITHUB_OUTPUT" + exit 0 + } - # --------------------------------------------------------------------------- - # Extract fields via jq. - # --------------------------------------------------------------------------- - is_draft="$(jq -r '.isDraft' <<< "$pr_json")" - base_ref="$(jq -r '.baseRefName' <<< "$pr_json")" - head_repo="$(jq -r '.headRepository.nameWithOwner // empty' <<< "$pr_json")" - pr_state="$(jq -r '.state' <<< "$pr_json")" - merge_state="$(jq -r '.mergeStateStatus // empty' <<< "$pr_json")" - review_decision="$(jq -r '.reviewDecision // empty' <<< "$pr_json")" - has_label="$(jq -r ' - [.labels[].name] | any(. == "auto-merge" or . == "autofix") - ' <<< "$pr_json")" + # --------------------------------------------------------------------------- + # Extract fields via jq. + # --------------------------------------------------------------------------- + is_draft="$(jq -r '.isDraft' <<< "$pr_json")" + base_ref="$(jq -r '.baseRefName' <<< "$pr_json")" + head_repo="$(jq -r '.headRepository.nameWithOwner // empty' <<< "$pr_json")" + pr_state="$(jq -r '.state' <<< "$pr_json")" + merge_state="$(jq -r '.mergeStateStatus // empty' <<< "$pr_json")" + review_decision="$(jq -r '.reviewDecision // empty' <<< "$pr_json")" + has_label="$(jq -r ' + [.labels[].name] | any(. == "auto-merge" or . == "autofix") + ' <<< "$pr_json")" - current_repo="${GITHUB_REPOSITORY:-}" + current_repo="${GITHUB_REPOSITORY:-}" - # --------------------------------------------------------------------------- - # Accumulate failures. - # --------------------------------------------------------------------------- - FAILURES=() + # --------------------------------------------------------------------------- + # Accumulate failures. + # --------------------------------------------------------------------------- + FAILURES=() - [[ "$pr_state" != "OPEN" ]] && \ - FAILURES+=("PR state is '${pr_state}' (requires OPEN)") + [[ "$pr_state" != "OPEN" ]] && \ + FAILURES+=("PR state is '${pr_state}' (requires OPEN)") - [[ "$is_draft" == "true" ]] && \ - FAILURES+=("PR is a draft") + [[ "$is_draft" == "true" ]] && \ + FAILURES+=("PR is a draft") - [[ "$base_ref" != "main" ]] && \ - FAILURES+=("base branch is '${base_ref}' (requires 'main')") + [[ "$base_ref" != "main" ]] && \ + FAILURES+=("base branch is '${base_ref}' (requires 'main')") - if [[ -n "$current_repo" && -n "$head_repo" && "$head_repo" != "$current_repo" ]]; then - FAILURES+=("PR is from a fork (${head_repo}); fork PRs are not eligible") - fi + if [[ -n "$current_repo" && -n "$head_repo" && "$head_repo" != "$current_repo" ]]; then + FAILURES+=("PR is from a fork (${head_repo}); fork PRs are not eligible") + fi - [[ "$has_label" != "true" ]] && \ - FAILURES+=("missing 'auto-merge' or 'autofix' label") + [[ "$has_label" != "true" ]] && \ + FAILURES+=("missing 'auto-merge' or 'autofix' label") - case "$merge_state" in - DIRTY) FAILURES+=("merge conflicts detected (mergeStateStatus=DIRTY)") ;; - BEHIND) FAILURES+=("branch is behind base branch (mergeStateStatus=BEHIND)") ;; - BLOCKED) FAILURES+=("merge is blocked by required status checks (mergeStateStatus=BLOCKED)") ;; - esac + case "$merge_state" in + DIRTY) FAILURES+=("merge conflicts detected (mergeStateStatus=DIRTY)") ;; + BEHIND) FAILURES+=("branch is behind base branch (mergeStateStatus=BEHIND)") ;; + BLOCKED) FAILURES+=("merge is blocked by required status checks (mergeStateStatus=BLOCKED)") ;; + esac - if [[ "$review_decision" == "CHANGES_REQUESTED" ]]; then - FAILURES+=("has CHANGES_REQUESTED review") - elif [[ "$review_decision" != "APPROVED" ]]; then - FAILURES+=("awaiting required approval (reviewDecision='${review_decision}')") - fi + if [[ "$review_decision" == "CHANGES_REQUESTED" ]]; then + FAILURES+=("has CHANGES_REQUESTED review") + elif [[ "$review_decision" != "APPROVED" ]]; then + FAILURES+=("awaiting required approval (reviewDecision='${review_decision}')") + fi - # --------------------------------------------------------------------------- - # Emit outputs. - # --------------------------------------------------------------------------- - if (( ${#FAILURES[@]} == 0 )); then - echo "eligible=true" >> "$GITHUB_OUTPUT" - echo "reason=All eligibility checks passed." >> "$GITHUB_OUTPUT" - echo "✅ PR #${PR_NUMBER} is eligible for auto-merge." - else - echo "eligible=false" >> "$GITHUB_OUTPUT" - joined="$(printf '%s; ' "${FAILURES[@]}")" - joined="${joined%; }" # trim trailing '; ' - printf 'reason=%s\n' "${joined}" >> "$GITHUB_OUTPUT" - echo "❌ PR #${PR_NUMBER} is NOT eligible: ${joined}" - fi + # --------------------------------------------------------------------------- + # Emit outputs. + # --------------------------------------------------------------------------- + if (( ${#FAILURES[@]} == 0 )); then + echo "eligible=true" >> "$GITHUB_OUTPUT" + echo "reason=All eligibility checks passed." >> "$GITHUB_OUTPUT" + echo "✅ PR #${PR_NUMBER} is eligible for auto-merge." + else + echo "eligible=false" >> "$GITHUB_OUTPUT" + joined="$(printf '%s; ' "${FAILURES[@]}")" + joined="${joined%; }" # trim trailing '; ' + printf 'reason=%s\n' "${joined}" >> "$GITHUB_OUTPUT" + echo "❌ PR #${PR_NUMBER} is NOT eligible: ${joined}" + fi diff --git a/ai-projects/chat-cli/src/chat_providers.py b/ai-projects/chat-cli/src/chat_providers.py index 6e91dd5f1..7406c8f51 100644 --- a/ai-projects/chat-cli/src/chat_providers.py +++ b/ai-projects/chat-cli/src/chat_providers.py @@ -1026,9 +1026,7 @@ def _complete_via_http(self, messages: list[RoleMessage], stream: bool) -> Itera if stream: def _gen() -> Generator[str, None, None]: - with urllib.request.urlopen( - req, timeout=timeout_seconds - ) as resp: # noqa: S310 - local configurable endpoint + with urllib.request.urlopen(req, timeout=timeout_seconds) as resp: # noqa: S310 - local configurable endpoint for raw_line in resp: line = raw_line.decode("utf-8", errors="replace").strip() if not line or not line.startswith("data:"): diff --git a/tests/test_auto_fix_workflow.py b/tests/test_auto_fix_workflow.py index 39ea1fbb2..4339dffff 100644 --- a/tests/test_auto_fix_workflow.py +++ b/tests/test_auto_fix_workflow.py @@ -71,9 +71,7 @@ def test_auto_fix_has_cleanup_step() -> None: "are accidentally included in the autofix branch" ) - cleanup_step = next( - step for step in steps if step["name"] == "Revert workflow file changes and remove stray files" - ) + cleanup_step = next(step for step in steps if step["name"] == "Revert workflow file changes and remove stray files") assert "detect_output.txt" in cleanup_step["run"], "Must remove stray detect_output.txt" assert ".github/workflows" in cleanup_step["run"], "Must revert workflow file changes" assert "git restore" in cleanup_step["run"], "Must use git restore to revert workflow changes" diff --git a/tests/test_auto_merge_workflow.py b/tests/test_auto_merge_workflow.py index 089681afa..7fabd8dcf 100644 --- a/tests/test_auto_merge_workflow.py +++ b/tests/test_auto_merge_workflow.py @@ -71,17 +71,14 @@ def test_auto_merge_has_pull_request_trigger() -> None: def test_auto_merge_has_check_run_trigger() -> None: wf = _load_auto_merge() assert "check_run" in _get_triggers(wf), ( - "auto-merge.yml must trigger on check_run events so it fires when " - "'All Gates Passed' completes" + "auto-merge.yml must trigger on check_run events so it fires when 'All Gates Passed' completes" ) def test_auto_merge_check_run_trigger_on_completed() -> None: wf = _load_auto_merge() check_run = _get_triggers(wf)["check_run"] - assert "completed" in check_run.get("types", []), ( - "check_run trigger must include the 'completed' type" - ) + assert "completed" in check_run.get("types", []), "check_run trigger must include the 'completed' type" def test_auto_merge_pull_request_trigger_includes_labeled_unlabeled() -> None: @@ -108,9 +105,7 @@ def test_auto_merge_has_disable_job() -> None: def test_auto_merge_has_merge_on_gate_pass_job() -> None: wf = _load_auto_merge() - assert "merge-on-gate-pass" in wf["jobs"], ( - "auto-merge.yml must have a 'merge-on-gate-pass' job" - ) + assert "merge-on-gate-pass" in wf["jobs"], "auto-merge.yml must have a 'merge-on-gate-pass' job" def test_auto_merge_has_bot_approve_job() -> None: @@ -142,20 +137,14 @@ def test_merge_on_gate_pass_filters_all_gates_passed_name() -> None: def test_merge_on_gate_pass_filters_success_conclusion() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["merge-on-gate-pass"].get("if", "") - assert "success" in job_if, ( - "merge-on-gate-pass must only fire when the check_run conclusion is 'success'" - ) + assert "success" in job_if, "merge-on-gate-pass must only fire when the check_run conclusion is 'success'" def test_merge_on_gate_pass_has_write_permissions() -> None: wf = _load_auto_merge() perms = wf["jobs"]["merge-on-gate-pass"].get("permissions", {}) - assert perms.get("contents") == "write", ( - "merge-on-gate-pass needs contents:write to perform merges" - ) - assert perms.get("pull-requests") == "write", ( - "merge-on-gate-pass needs pull-requests:write to post comments" - ) + assert perms.get("contents") == "write", "merge-on-gate-pass needs contents:write to perform merges" + assert perms.get("pull-requests") == "write", "merge-on-gate-pass needs pull-requests:write to post comments" # --------------------------------------------------------------------------- @@ -167,25 +156,21 @@ def test_enable_job_guards_against_forks() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["enable"].get("if", "") assert "head.repo.full_name == github.repository" in job_if, ( - "enable job must guard against fork PRs by checking " - "head.repo.full_name == github.repository" + "enable job must guard against fork PRs by checking head.repo.full_name == github.repository" ) def test_enable_job_guards_against_drafts() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["enable"].get("if", "") - assert "draft" in job_if, ( - "enable job must guard against draft PRs" - ) + assert "draft" in job_if, "enable job must guard against draft PRs" def test_enable_job_fires_on_pull_request_event() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["enable"].get("if", "") assert "github.event_name == 'pull_request'" in job_if, ( - "enable job must check github.event_name == 'pull_request' to avoid " - "running on check_run events" + "enable job must check github.event_name == 'pull_request' to avoid running on check_run events" ) @@ -197,29 +182,21 @@ def test_enable_job_fires_on_pull_request_event() -> None: def test_disable_job_guards_against_forks() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["disable"].get("if", "") - assert "head.repo.full_name == github.repository" in job_if, ( - "disable job must guard against fork PRs" - ) + assert "head.repo.full_name == github.repository" in job_if, "disable job must guard against fork PRs" def test_disable_job_requires_unlabeled_action() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["disable"].get("if", "") - assert "unlabeled" in job_if, ( - "disable job must check for the 'unlabeled' action" - ) + assert "unlabeled" in job_if, "disable job must check for the 'unlabeled' action" def test_disable_job_checks_no_remaining_label() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["disable"].get("if", "") # Must verify both labels are absent before disabling - assert "auto-merge" in job_if, ( - "disable job must check that 'auto-merge' label is no longer present" - ) - assert "autofix" in job_if, ( - "disable job must check that 'autofix' label is no longer present" - ) + assert "auto-merge" in job_if, "disable job must check that 'auto-merge' label is no longer present" + assert "autofix" in job_if, "disable job must check that 'autofix' label is no longer present" # --------------------------------------------------------------------------- @@ -230,40 +207,28 @@ def test_disable_job_checks_no_remaining_label() -> None: def test_bot_approve_gated_by_variable() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["bot-approve"].get("if", "") - assert "AUTO_MERGE_BOT_APPROVE" in job_if, ( - "bot-approve job must be gated by the AUTO_MERGE_BOT_APPROVE variable" - ) + assert "AUTO_MERGE_BOT_APPROVE" in job_if, "bot-approve job must be gated by the AUTO_MERGE_BOT_APPROVE variable" def test_bot_approve_guards_against_forks() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["bot-approve"].get("if", "") - assert "head.repo.full_name == github.repository" in job_if, ( - "bot-approve must guard against fork PRs" - ) + assert "head.repo.full_name == github.repository" in job_if, "bot-approve must guard against fork PRs" def test_bot_approve_skips_drafts() -> None: wf = _load_auto_merge() job_if = wf["jobs"]["bot-approve"].get("if", "") - assert "draft" in job_if, ( - "bot-approve must skip draft PRs" - ) + assert "draft" in job_if, "bot-approve must skip draft PRs" def test_bot_approve_allowlist_defined_in_env() -> None: wf = _load_auto_merge() env = wf.get("env", {}) - assert "BOT_APPROVE_ALLOWLIST" in env, ( - "BOT_APPROVE_ALLOWLIST must be declared in the workflow-level env block" - ) + assert "BOT_APPROVE_ALLOWLIST" in env, "BOT_APPROVE_ALLOWLIST must be declared in the workflow-level env block" allowlist = env["BOT_APPROVE_ALLOWLIST"] - assert "github-actions[bot]" in allowlist, ( - "github-actions[bot] must be in BOT_APPROVE_ALLOWLIST" - ) - assert "copilot-swe-agent[bot]" in allowlist, ( - "copilot-swe-agent[bot] must be in BOT_APPROVE_ALLOWLIST" - ) + assert "github-actions[bot]" in allowlist, "github-actions[bot] must be in BOT_APPROVE_ALLOWLIST" + assert "copilot-swe-agent[bot]" in allowlist, "copilot-swe-agent[bot] must be in BOT_APPROVE_ALLOWLIST" # --------------------------------------------------------------------------- @@ -303,91 +268,65 @@ def test_auto_merge_on_ci_stub_has_no_automatic_trigger() -> None: def test_eligibility_action_exists() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" - assert action_path.exists(), ( - "check-auto-merge-eligibility/action.yml must exist" - ) + assert action_path.exists(), "check-auto-merge-eligibility/action.yml must exist" def test_eligibility_action_has_pr_number_input() -> None: action = _load_eligibility_action() inputs = action.get("inputs", {}) - assert "pr-number" in inputs, ( - "eligibility action must define a 'pr-number' input" - ) - assert inputs["pr-number"].get("required") is True, ( - "'pr-number' input must be required" - ) + assert "pr-number" in inputs, "eligibility action must define a 'pr-number' input" + assert inputs["pr-number"].get("required") is True, "'pr-number' input must be required" def test_eligibility_action_has_github_token_input() -> None: action = _load_eligibility_action() inputs = action.get("inputs", {}) - assert "github-token" in inputs, ( - "eligibility action must define a 'github-token' input" - ) + assert "github-token" in inputs, "eligibility action must define a 'github-token' input" def test_eligibility_action_outputs_eligible() -> None: action = _load_eligibility_action() outputs = action.get("outputs", {}) - assert "eligible" in outputs, ( - "eligibility action must output 'eligible'" - ) + assert "eligible" in outputs, "eligibility action must output 'eligible'" def test_eligibility_action_outputs_reason() -> None: action = _load_eligibility_action() outputs = action.get("outputs", {}) - assert "reason" in outputs, ( - "eligibility action must output 'reason'" - ) + assert "reason" in outputs, "eligibility action must output 'reason'" def test_eligibility_action_is_composite() -> None: action = _load_eligibility_action() - assert action.get("runs", {}).get("using") == "composite", ( - "eligibility action must use 'composite' runner" - ) + assert action.get("runs", {}).get("using") == "composite", "eligibility action must use 'composite' runner" def test_eligibility_action_checks_draft() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" content = action_path.read_text(encoding="utf-8") - assert "draft" in content.lower(), ( - "eligibility action script must check for draft status" - ) + assert "draft" in content.lower(), "eligibility action script must check for draft status" def test_eligibility_action_checks_base_branch() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" content = action_path.read_text(encoding="utf-8") - assert "main" in content, ( - "eligibility action must verify the PR targets 'main'" - ) + assert "main" in content, "eligibility action must verify the PR targets 'main'" def test_eligibility_action_checks_fork() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" content = action_path.read_text(encoding="utf-8") - assert "fork" in content.lower(), ( - "eligibility action must guard against fork PRs" - ) + assert "fork" in content.lower(), "eligibility action must guard against fork PRs" def test_eligibility_action_checks_label() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" content = action_path.read_text(encoding="utf-8") - assert "auto-merge" in content, ( - "eligibility action must verify the 'auto-merge' label" - ) - assert "autofix" in content, ( - "eligibility action must verify the 'autofix' label" - ) + assert "auto-merge" in content, "eligibility action must verify the 'auto-merge' label" + assert "autofix" in content, "eligibility action must verify the 'autofix' label" def test_eligibility_action_checks_changes_requested() -> None: action_path = ACTIONS_DIR / "check-auto-merge-eligibility" / "action.yml" content = action_path.read_text(encoding="utf-8") - assert "CHANGES_REQUESTED" in content, ( - "eligibility action must block on CHANGES_REQUESTED reviews" - ) + assert "CHANGES_REQUESTED" in content, "eligibility action must block on CHANGES_REQUESTED reviews"