diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae2513f..73af864 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,3 +35,30 @@ jobs: - name: Run tests run: pytest -q + + lint: + name: "ruff" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-ruff-${{ hashFiles('pyproject.toml') }} + + - name: Install ruff + run: pip install ruff + + - name: ruff check + run: ruff check . + + - name: ruff format --check + run: ruff format --check . diff --git a/pyproject.toml b/pyproject.toml index 6708a58..2dbb47d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,3 +36,23 @@ packages = ["src/conclave"] asyncio_mode = "auto" testpaths = ["tests"] pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long — ruff format handles wrapping +] + +[tool.ruff.lint.isort] +known-first-party = ["conclave"] diff --git a/src/conclave/adapters/__init__.py b/src/conclave/adapters/__init__.py index d7d38b5..888f094 100644 --- a/src/conclave/adapters/__init__.py +++ b/src/conclave/adapters/__init__.py @@ -20,7 +20,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING from ..registry import PROVIDER_ENV_VARS, provider_prefix from .anthropic import AnthropicAdapter @@ -58,9 +59,7 @@ def _openai_compat_adapter(prefix: str) -> OpenAICompatAdapter: ) -def resolve_adapter( - model_id: str, config: "ConclaveConfig | None" = None -) -> ProviderAdapter: +def resolve_adapter(model_id: str, config: ConclaveConfig | None = None) -> ProviderAdapter: """Resolve a model id to the adapter that speaks its provider's wire format. Args: diff --git a/src/conclave/adapters/anthropic.py b/src/conclave/adapters/anthropic.py index 0c6080e..74df4e7 100644 --- a/src/conclave/adapters/anthropic.py +++ b/src/conclave/adapters/anthropic.py @@ -16,8 +16,6 @@ from __future__ import annotations -from typing import Optional - from ..models import TokenUsage from ..registry import PROVIDER_ENV_VARS from .base import ProviderError @@ -86,9 +84,7 @@ def build_request( body["system"] = "\n\n".join(system_parts) return self.completions_url, headers, body - def parse_response( - self, status: int, payload: object - ) -> tuple[str, Optional[TokenUsage]]: + def parse_response(self, status: int, payload: object) -> tuple[str, TokenUsage | None]: """Concatenate ``content[*].text`` and map usage. See :meth:`ProviderAdapter.parse_response`. @@ -100,9 +96,7 @@ def parse_response( content = payload.get("content") if not isinstance(content, list): - raise ProviderError( - "anthropic: malformed response, missing content array" - ) + raise ProviderError("anthropic: malformed response, missing content array") text = "".join( block.get("text", "") for block in content @@ -115,7 +109,7 @@ def parse_response( return text, usage -def _parse_usage(raw: object) -> Optional[TokenUsage]: +def _parse_usage(raw: object) -> TokenUsage | None: """Map Anthropic ``input_tokens``/``output_tokens`` to :class:`TokenUsage`.""" if not isinstance(raw, dict): return None diff --git a/src/conclave/adapters/base.py b/src/conclave/adapters/base.py index d13bf1e..b92bcd6 100644 --- a/src/conclave/adapters/base.py +++ b/src/conclave/adapters/base.py @@ -23,7 +23,7 @@ import os import re -from typing import Optional, Protocol, runtime_checkable +from typing import Protocol, runtime_checkable from ..models import TokenUsage from ..registry import PROVIDER_ENV_VARS @@ -33,9 +33,7 @@ # Matches standalone provider-style keys: sk-..., xai-..., pplx-..., AIza... etc. _KEY_LIKE_RE = re.compile(r"\b(?:sk|xai|pplx|AIza)[A-Za-z0-9._\-]{8,}\b") # Matches an x-api-key / x-goog-api-key header echoed with its value. -_HEADER_KEY_RE = re.compile( - r"(x-(?:goog-)?api-key)\s*[:=]\s*[A-Za-z0-9._\-]+", re.IGNORECASE -) +_HEADER_KEY_RE = re.compile(r"(x-(?:goog-)?api-key)\s*[:=]\s*[A-Za-z0-9._\-]+", re.IGNORECASE) _REDACTED = "[REDACTED]" @@ -123,9 +121,7 @@ def build_request( """ ... - def parse_response( - self, status: int, payload: object - ) -> tuple[str, Optional[TokenUsage]]: + def parse_response(self, status: int, payload: object) -> tuple[str, TokenUsage | None]: """Parse a provider response into ``(text, usage)``. Args: diff --git a/src/conclave/adapters/gemini.py b/src/conclave/adapters/gemini.py index dbab003..050f58b 100644 --- a/src/conclave/adapters/gemini.py +++ b/src/conclave/adapters/gemini.py @@ -18,8 +18,6 @@ from __future__ import annotations -from typing import Optional - from ..models import TokenUsage from ..registry import PROVIDER_ENV_VARS from .base import ProviderError @@ -87,14 +85,10 @@ def build_request( }, } if system_parts: - body["systemInstruction"] = { - "parts": [{"text": "\n\n".join(system_parts)}] - } + body["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_parts)}]} return url, headers, body - def parse_response( - self, status: int, payload: object - ) -> tuple[str, Optional[TokenUsage]]: + def parse_response(self, status: int, payload: object) -> tuple[str, TokenUsage | None]: """Concatenate the first candidate's text parts. See base protocol.""" if status < 200 or status >= 300: raise ProviderError(_status_error(status, payload)) @@ -111,9 +105,7 @@ def parse_response( ) from exc text = "".join( - part.get("text", "") - for part in parts - if isinstance(part, dict) and "text" in part + part.get("text", "") for part in parts if isinstance(part, dict) and "text" in part ) if not text: raise ProviderError("gemini: empty response (no text parts)") @@ -122,7 +114,7 @@ def parse_response( return text, usage -def _parse_usage(raw: object) -> Optional[TokenUsage]: +def _parse_usage(raw: object) -> TokenUsage | None: """Map Gemini ``usageMetadata`` counts to :class:`TokenUsage`.""" if not isinstance(raw, dict): return None diff --git a/src/conclave/adapters/openai_compat.py b/src/conclave/adapters/openai_compat.py index 0be033c..e1bdaca 100644 --- a/src/conclave/adapters/openai_compat.py +++ b/src/conclave/adapters/openai_compat.py @@ -14,8 +14,6 @@ from __future__ import annotations -from typing import Optional - from ..models import TokenUsage from .base import ProviderError @@ -45,7 +43,7 @@ def __init__( prefix: str, completions_url: str, env_vars: tuple[str, ...], - max_tokens: Optional[int] = None, + max_tokens: int | None = None, ) -> None: self.prefix = prefix self.completions_url = completions_url @@ -82,9 +80,7 @@ def build_request( body["max_tokens"] = self.max_tokens return self.completions_url, headers, body - def parse_response( - self, status: int, payload: object - ) -> tuple[str, Optional[TokenUsage]]: + def parse_response(self, status: int, payload: object) -> tuple[str, TokenUsage | None]: """Parse ``choices[0].message.content`` + ``usage``. See :meth:`ProviderAdapter.parse_response`. @@ -92,9 +88,7 @@ def parse_response( if status < 200 or status >= 300: raise ProviderError(_status_error(self.prefix, status, payload)) if not isinstance(payload, dict): - raise ProviderError( - f"{self.prefix}: non-JSON response body (status {status})" - ) + raise ProviderError(f"{self.prefix}: non-JSON response body (status {status})") try: choices = payload["choices"] @@ -112,7 +106,7 @@ def parse_response( return content, usage -def _parse_usage(raw: object) -> Optional[TokenUsage]: +def _parse_usage(raw: object) -> TokenUsage | None: """Map an OpenAI-style ``usage`` block to :class:`TokenUsage`, or ``None``.""" if not isinstance(raw, dict): return None diff --git a/src/conclave/cli.py b/src/conclave/cli.py index 4835680..aaca8e6 100644 --- a/src/conclave/cli.py +++ b/src/conclave/cli.py @@ -12,7 +12,6 @@ from __future__ import annotations import json -from typing import Optional import typer from rich.console import Console @@ -60,9 +59,7 @@ def _answer_panel(ans, *, border: str = "cyan") -> Panel: def _print_skipped(result: CouncilResult) -> None: """Print the skipped-no-key warning line if any members were skipped.""" if result.skipped: - err_console.print( - f"[yellow]Skipped (no key): {', '.join(result.skipped)}[/yellow]" - ) + err_console.print(f"[yellow]Skipped (no key): {', '.join(result.skipped)}[/yellow]") def _print_synthesis(result: CouncilResult, title: str = "SYNTHESIS") -> None: @@ -118,8 +115,7 @@ def _render_adversarial(result: CouncilResult) -> None: console.print( Panel( adv.verdict, - title=f"[bold green]VERDICT[/bold green] " - f"({adv.judge} · {adv.judge_model_id})", + title=f"[bold green]VERDICT[/bold green] ({adv.judge} · {adv.judge_model_id})", border_style="green", ) ) @@ -154,13 +150,13 @@ def ask( "-m", help="Run mode: synthesize | raw | debate | adversarial.", ), - synthesizer: Optional[str] = typer.Option( + synthesizer: str | None = typer.Option( None, "--synthesizer", "-s", help="Override the synthesizer/judge model name." ), rounds: int = typer.Option( 2, "--rounds", "-r", help="Number of debate rounds (debate mode only).", min=1 ), - proposer: Optional[str] = typer.Option( + proposer: str | None = typer.Option( None, "--proposer", "-p", @@ -174,8 +170,7 @@ def ask( mode_lower = mode.lower() if mode_lower not in _VALID_MODES: err_console.print( - f"[red]Unknown mode '{mode}'. Choose one of: " - f"{', '.join(sorted(_VALID_MODES))}.[/red]" + f"[red]Unknown mode '{mode}'. Choose one of: {', '.join(sorted(_VALID_MODES))}.[/red]" ) raise typer.Exit(code=2) @@ -199,8 +194,7 @@ def ask( if not result.answers: err_console.print( - "[red]No council members had keys available. " - "Run 'conclave providers' to check.[/red]" + "[red]No council members had keys available. Run 'conclave providers' to check.[/red]" ) raise typer.Exit(code=1) @@ -224,9 +218,7 @@ def providers() -> None: table.add_row(name, model_id, mark, source) console.print(table) - console.print( - f"[dim]synthesizer default: {cfg.synthesizer} · conclave {__version__}[/dim]" - ) + console.print(f"[dim]synthesizer default: {cfg.synthesizer} · conclave {__version__}[/dim]") def _builtin_default_note() -> str: diff --git a/src/conclave/config.py b/src/conclave/config.py index 59be344..bd2a816 100644 --- a/src/conclave/config.py +++ b/src/conclave/config.py @@ -124,10 +124,7 @@ def load_config(path: Path | None = None) -> ConclaveConfig: merged_models = dict(DEFAULT_MODELS) merged_models.update(raw.get("models", {}) or {}) - councils = { - name: list(members) - for name, members in (raw.get("councils", {}) or {}).items() - } + councils = {name: list(members) for name, members in (raw.get("councils", {}) or {}).items()} # Always provide a "default" council if none defined: all known providers. councils.setdefault("default", list(DEFAULT_MODELS.keys())) diff --git a/src/conclave/council.py b/src/conclave/council.py index 3f2695c..47af852 100644 --- a/src/conclave/council.py +++ b/src/conclave/council.py @@ -13,7 +13,7 @@ from __future__ import annotations import asyncio -from typing import Callable +from collections.abc import Callable from .config import ConclaveConfig, load_config from .logging import get_logger @@ -90,7 +90,7 @@ def _available_members(self) -> tuple[list[tuple[str, str]], list[str]]: async def fan_out( self, members: list[tuple[str, str]], - messages_for: "MessagesFor", + messages_for: MessagesFor, ) -> list[ModelAnswer]: """Fan a per-member message list out concurrently and collect results. @@ -123,7 +123,7 @@ async def fan_out( # against any unexpected raise so one bad member can't abort the gather. gathered = await asyncio.gather(*tasks, return_exceptions=True) answers: list[ModelAnswer] = [] - for (name, model_id), outcome in zip(members, gathered): + for (name, model_id), outcome in zip(members, gathered, strict=True): if isinstance(outcome, ModelAnswer): answers.append(outcome) else: @@ -161,9 +161,7 @@ async def ask(self, prompt: str, synthesize: bool = True) -> CouncilResult: return result base_messages = [{"role": "user", "content": prompt}] - result.answers = await self.fan_out( - members, lambda _name, _model_id: base_messages - ) + result.answers = await self.fan_out(members, lambda _name, _model_id: base_messages) if synthesize: await self._synthesize(result) @@ -189,9 +187,7 @@ async def _synthesize(self, result: CouncilResult) -> None: logger.warning(result.synthesis_error) return - blocks = "\n\n".join( - f"### Answer from {a.name} ({a.model_id})\n{a.answer}" for a in usable - ) + blocks = "\n\n".join(f"### Answer from {a.name} ({a.model_id})\n{a.answer}" for a in usable) user_content = ( f"Original prompt:\n{result.prompt}\n\n" f"Council answers:\n\n{blocks}\n\n" @@ -203,9 +199,7 @@ async def _synthesize(self, result: CouncilResult) -> None: else: result.synthesis_error = answer.error - async def synthesize_blocks( - self, system_prompt: str, user_content: str - ) -> ModelAnswer: + async def synthesize_blocks(self, system_prompt: str, user_content: str) -> ModelAnswer: """Call the synthesizer model with an arbitrary system + user message. Shared by synthesize mode, debate's final consolidation, and the @@ -244,9 +238,7 @@ async def debate(self, prompt: str, rounds: int = 2) -> CouncilResult: return await run_debate(self, prompt, rounds=rounds) - async def adversarial( - self, prompt: str, proposer: str | None = None - ) -> CouncilResult: + async def adversarial(self, prompt: str, proposer: str | None = None) -> CouncilResult: """Run propose -> refute -> verdict. See :func:`conclave.modes.run_adversarial`. ``proposer`` (friendly name) defaults to the first requested member. @@ -256,15 +248,14 @@ async def adversarial( return await run_adversarial(self, prompt, proposer=proposer) @staticmethod - def _run_sync(coro_factory: Callable[[], "asyncio.Future | object"], label: str): + def _run_sync(coro_factory: Callable[[], asyncio.Future | object], label: str): """Run an async council method synchronously, guarding nested loops.""" try: asyncio.get_running_loop() except RuntimeError: return asyncio.run(coro_factory()) raise RuntimeError( - f"{label}() called from within a running event loop; " - "await the async method instead" + f"{label}() called from within a running event loop; await the async method instead" ) def ask_sync(self, prompt: str, synthesize: bool = True) -> CouncilResult: @@ -273,19 +264,13 @@ def ask_sync(self, prompt: str, synthesize: bool = True) -> CouncilResult: Safe to call from non-async code. Raises ``RuntimeError`` if invoked from inside a running event loop -- use :meth:`ask` there instead. """ - return self._run_sync( - lambda: self.ask(prompt, synthesize=synthesize), "ask_sync" - ) + return self._run_sync(lambda: self.ask(prompt, synthesize=synthesize), "ask_sync") def debate_sync(self, prompt: str, rounds: int = 2) -> CouncilResult: """Synchronous wrapper around :meth:`debate`.""" - return self._run_sync( - lambda: self.debate(prompt, rounds=rounds), "debate_sync" - ) + return self._run_sync(lambda: self.debate(prompt, rounds=rounds), "debate_sync") - def adversarial_sync( - self, prompt: str, proposer: str | None = None - ) -> CouncilResult: + def adversarial_sync(self, prompt: str, proposer: str | None = None) -> CouncilResult: """Synchronous wrapper around :meth:`adversarial`.""" return self._run_sync( lambda: self.adversarial(prompt, proposer=proposer), "adversarial_sync" diff --git a/src/conclave/logging.py b/src/conclave/logging.py index 972293a..1f65c5e 100644 --- a/src/conclave/logging.py +++ b/src/conclave/logging.py @@ -28,9 +28,7 @@ def get_logger(name: str = "conclave") -> logging.Logger: level_name = os.environ.get("CONCLAVE_LOG_LEVEL", "WARNING").upper() level = getattr(logging, level_name, logging.WARNING) handler = logging.StreamHandler() - handler.setFormatter( - logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s") - ) + handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")) root.addHandler(handler) root.setLevel(level) root.propagate = False diff --git a/src/conclave/models.py b/src/conclave/models.py index 04c17c2..25803b8 100644 --- a/src/conclave/models.py +++ b/src/conclave/models.py @@ -6,8 +6,6 @@ from __future__ import annotations -from typing import Optional - from pydantic import BaseModel, Field @@ -33,10 +31,10 @@ class ModelAnswer(BaseModel): name: str model_id: str - answer: Optional[str] = None + answer: str | None = None latency_s: float = 0.0 - usage: Optional[TokenUsage] = None - error: Optional[str] = None + usage: TokenUsage | None = None + error: str | None = None @property def ok(self) -> bool: @@ -81,10 +79,10 @@ class AdversarialResult(BaseModel): proposer: str proposal: ModelAnswer critiques: list[ModelAnswer] = Field(default_factory=list) - verdict: Optional[str] = None - verdict_error: Optional[str] = None - judge: Optional[str] = None - judge_model_id: Optional[str] = None + verdict: str | None = None + verdict_error: str | None = None + judge: str | None = None + judge_model_id: str | None = None @property def successful_critiques(self) -> list[ModelAnswer]: @@ -117,13 +115,13 @@ class CouncilResult(BaseModel): prompt: str mode: str = "synthesize" answers: list[ModelAnswer] = Field(default_factory=list) - synthesizer: Optional[str] = None - synthesizer_model_id: Optional[str] = None - synthesis: Optional[str] = None - synthesis_error: Optional[str] = None + synthesizer: str | None = None + synthesizer_model_id: str | None = None + synthesis: str | None = None + synthesis_error: str | None = None skipped: list[str] = Field(default_factory=list) rounds: list[DebateRound] = Field(default_factory=list) - adversarial: Optional[AdversarialResult] = None + adversarial: AdversarialResult | None = None @property def successful_answers(self) -> list[ModelAnswer]: diff --git a/src/conclave/modes.py b/src/conclave/modes.py index 205b0e5..9f0541b 100644 --- a/src/conclave/modes.py +++ b/src/conclave/modes.py @@ -35,9 +35,7 @@ logger = get_logger("modes") -async def run_debate( - council: "Council", prompt: str, rounds: int = 2 -) -> CouncilResult: +async def run_debate(council: Council, prompt: str, rounds: int = 2) -> CouncilResult: """Run a multi-round debate and return a structured :class:`CouncilResult`. Args: @@ -62,8 +60,7 @@ async def run_debate( # Stable letter labels by initial position; survives drop-outs. letters = { - name: prompts.LETTERS[i % len(prompts.LETTERS)] - for i, (name, _) in enumerate(members) + name: prompts.LETTERS[i % len(prompts.LETTERS)] for i, (name, _) in enumerate(members) } survivors = list(members) # (name, model_id) pairs still in the debate @@ -74,9 +71,7 @@ async def run_debate( logger.warning("debate ended early at round %d: no survivors", round_no) break - messages_for = _debate_messages_for( - prompt, round_no, rounds, prior, letters - ) + messages_for = _debate_messages_for(prompt, round_no, rounds, prior, letters) answers = await council.fan_out(survivors, messages_for) result.rounds.append(DebateRound(round_number=round_no, answers=answers)) @@ -119,23 +114,19 @@ def _debate_messages_for( return lambda _name, _model_id: base def messages_for(name: str, _model_id: str) -> list[dict[str, str]]: - peer_block = prompts.anonymized_peer_block( - name, letters[name], prior, letters - ) + peer_block = prompts.anonymized_peer_block(name, letters[name], prior, letters) return [ {"role": "system", "content": prompts.DEBATE_SYSTEM}, { "role": "user", - "content": prompts.debate_round_user( - prompt, round_no, rounds, peer_block - ), + "content": prompts.debate_round_user(prompt, round_no, rounds, peer_block), }, ] return messages_for -async def _debate_synthesize(council: "Council", result: CouncilResult) -> None: +async def _debate_synthesize(council: Council, result: CouncilResult) -> None: """Consolidate the final round's surviving answers via the synthesizer.""" final = result.rounds[-1].successful_answers if result.rounds else [] if not final: @@ -166,7 +157,7 @@ async def _debate_synthesize(council: "Council", result: CouncilResult) -> None: async def run_adversarial( - council: "Council", prompt: str, proposer: str | None = None + council: Council, prompt: str, proposer: str | None = None ) -> CouncilResult: """Run a propose -> refute -> verdict pass and return a :class:`CouncilResult`. @@ -239,9 +230,7 @@ def critic_messages(_name: str, _model_id: str) -> list[dict[str, str]]: return critic_messages -def _pick_proposer( - members: list[tuple[str, str]], requested: str -) -> tuple[str, str]: +def _pick_proposer(members: list[tuple[str, str]], requested: str) -> tuple[str, str]: """Return the requested proposer member, or the first available as fallback.""" for member in members: if member[0] == requested: @@ -249,9 +238,7 @@ def _pick_proposer( return members[0] -async def _adversarial_judge( - council: "Council", prompt: str, adv: AdversarialResult -) -> None: +async def _adversarial_judge(council: Council, prompt: str, adv: AdversarialResult) -> None: """Run the judge over the proposal + critiques, mutating ``adv``.""" judge_id = council.config.resolve_model_id(council.synthesizer) adv.judge = council.synthesizer @@ -259,8 +246,7 @@ async def _adversarial_judge( if not adv.proposal.ok: adv.verdict_error = ( - f"proposal from '{adv.proposer}' failed ({adv.proposal.error}); " - "no verdict produced" + f"proposal from '{adv.proposer}' failed ({adv.proposal.error}); no verdict produced" ) logger.warning(adv.verdict_error) return @@ -275,8 +261,7 @@ async def _adversarial_judge( usable_critiques = adv.successful_critiques if usable_critiques: critique_blocks = "\n\n".join( - f"### Critique from {c.name} ({c.model_id})\n{c.answer}" - for c in usable_critiques + f"### Critique from {c.name} ({c.model_id})\n{c.answer}" for c in usable_critiques ) else: critique_blocks = "(no usable critiques were produced)" diff --git a/src/conclave/prompts.py b/src/conclave/prompts.py index cb929f3..7ce5b38 100644 --- a/src/conclave/prompts.py +++ b/src/conclave/prompts.py @@ -70,9 +70,7 @@ def anonymized_peer_block( parts: list[str] = [] own = prior.get(self_name) if own is not None and own.ok: - parts.append( - f"### Your previous answer (you are Model {self_letter})\n{own.answer}" - ) + parts.append(f"### Your previous answer (you are Model {self_letter})\n{own.answer}") for name, ans in prior.items(): if name == self_name or not ans.ok: continue diff --git a/src/conclave/providers.py b/src/conclave/providers.py index bf49787..6e84400 100644 --- a/src/conclave/providers.py +++ b/src/conclave/providers.py @@ -16,7 +16,6 @@ import os import time -from typing import Optional from . import transport from .adapters import ProviderError, resolve_adapter @@ -29,7 +28,7 @@ logger = get_logger("providers") -def _resolve_key(adapter: ProviderAdapter) -> Optional[str]: +def _resolve_key(adapter: ProviderAdapter) -> str | None: """Read the active key VALUE for an adapter from the environment, or None. Walks the adapter's candidate env var names in order and returns the first @@ -77,9 +76,7 @@ async def call_model( except ProviderError as exc: latency = time.perf_counter() - started logger.warning("%s (%s) unresolved: %s", name, model_id, exc) - return ModelAnswer( - name=name, model_id=model_id, latency_s=latency, error=str(exc) - ) + return ModelAnswer(name=name, model_id=model_id, latency_s=latency, error=str(exc)) api_key = _resolve_key(adapter) if api_key is None: @@ -87,9 +84,7 @@ async def call_model( names = " or ".join(adapter.env_vars) or "(none)" msg = f"no API key in environment (set {names})" logger.warning("%s (%s) %s", name, model_id, msg) - return ModelAnswer( - name=name, model_id=model_id, latency_s=latency, error=msg - ) + return ModelAnswer(name=name, model_id=model_id, latency_s=latency, error=msg) try: url, headers, body = adapter.build_request( @@ -112,13 +107,9 @@ async def call_model( # transport message and any composed string. message = redact(str(exc)) logger.warning("%s (%s) failed: %s", name, model_id, message) - return ModelAnswer( - name=name, model_id=model_id, latency_s=latency, error=message - ) + return ModelAnswer(name=name, model_id=model_id, latency_s=latency, error=message) except Exception as exc: # noqa: BLE001 -- never let an unexpected raise kill the run latency = time.perf_counter() - started message = redact(f"{type(exc).__name__}: {exc}") logger.warning("%s (%s) unexpected error: %s", name, model_id, message) - return ModelAnswer( - name=name, model_id=model_id, latency_s=latency, error=message - ) + return ModelAnswer(name=name, model_id=model_id, latency_s=latency, error=message) diff --git a/src/conclave/transport.py b/src/conclave/transport.py index 72a78e2..d056df3 100644 --- a/src/conclave/transport.py +++ b/src/conclave/transport.py @@ -13,8 +13,6 @@ from __future__ import annotations -from typing import Optional - import httpx from .logging import get_logger @@ -23,7 +21,7 @@ # One shared, lazily-created client so connections are pooled across calls # within a process. httpx.AsyncClient is safe to share across concurrent tasks. -_client: Optional[httpx.AsyncClient] = None +_client: httpx.AsyncClient | None = None class TransportError(Exception): @@ -69,9 +67,7 @@ async def post_json( """ client = _get_client() try: - response = await client.post( - url, headers=headers, json=json_body, timeout=timeout - ) + response = await client.post(url, headers=headers, json=json_body, timeout=timeout) except httpx.TimeoutException as exc: raise TransportError(f"request timed out after {timeout:.0f}s") from exc except httpx.HTTPError as exc: diff --git a/tests/conftest.py b/tests/conftest.py index b968ff1..904aa0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,8 +19,8 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable import pytest @@ -61,9 +61,7 @@ def handler(model_id, messages, **kwargs): import conclave.council as council_mod def install(handler: Callable): - async def fake_call_model( - name, model_id, messages, *, temperature=0.7, timeout=120.0 - ): + async def fake_call_model(name, model_id, messages, *, temperature=0.7, timeout=120.0): # A tiny await so concurrency is genuinely exercised by gather. await asyncio.sleep(0) try: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 442b000..e056b60 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -22,7 +22,6 @@ from conclave.adapters.gemini import GeminiAdapter from conclave.adapters.openai_compat import OpenAICompatAdapter - # --------------------------------------------------------------------------- # # OpenAI-compatible adapter (openai / xai / perplexity) # --------------------------------------------------------------------------- # @@ -39,9 +38,7 @@ def _openai_adapter() -> OpenAICompatAdapter: def test_openai_compat_build_request(): adapter = _openai_adapter() messages = [{"role": "user", "content": "hi"}] - url, headers, body = adapter.build_request( - "openai/gpt-4.1", messages, 0.7, 120.0, "sk-secret" - ) + url, headers, body = adapter.build_request("openai/gpt-4.1", messages, 0.7, 120.0, "sk-secret") assert url == "https://api.openai.com/v1/chat/completions" assert headers["Authorization"] == "Bearer sk-secret" assert headers["Content-Type"] == "application/json" @@ -210,8 +207,7 @@ def test_gemini_build_request_role_mapping_and_system_instruction(): ) # Model embedded in the URL path; gemini/ prefix stripped. assert url == ( - "https://generativelanguage.googleapis.com/v1beta/models/" - "gemini-2.5-pro:generateContent" + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" ) # Auth header is x-goog-api-key (no Bearer). assert headers["x-goog-api-key"] == "AIza-secret" @@ -243,9 +239,7 @@ def test_gemini_build_request_configurable_max_output_tokens_no_system(): def test_gemini_parse_success(): adapter = GeminiAdapter() payload = { - "candidates": [ - {"content": {"parts": [{"text": "part one "}, {"text": "part two"}]}} - ], + "candidates": [{"content": {"parts": [{"text": "part one "}, {"text": "part two"}]}}], "usageMetadata": { "promptTokenCount": 8, "candidatesTokenCount": 6, diff --git a/tests/test_council.py b/tests/test_council.py index adc2798..c916a1c 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -72,21 +72,17 @@ async def test_concurrency_is_real(monkeypatch): """Members run concurrently: total time ~= slowest call, not the sum.""" _all_keys(monkeypatch) - from conclave.models import ModelAnswer import conclave.council as council_mod + from conclave.models import ModelAnswer # Replace call_model with a coroutine that sleeps, to prove gather concurrency. - async def sleepy_call_model( - name, model_id, messages, *, temperature=0.7, timeout=120.0 - ): + async def sleepy_call_model(name, model_id, messages, *, temperature=0.7, timeout=120.0): await asyncio.sleep(0.2) return ModelAnswer(name=name, model_id=model_id, answer=f"ok {model_id}") monkeypatch.setattr(council_mod, "call_model", sleepy_call_model) - council = Council( - models=["grok", "gemini", "perplexity"], config=_config() - ) + council = Council(models=["grok", "gemini", "perplexity"], config=_config()) start = asyncio.get_event_loop().time() result = await council.ask("hi", synthesize=False) elapsed = asyncio.get_event_loop().time() - start @@ -160,9 +156,7 @@ def handler(model, messages, **kwargs): patch_call_model(handler) - council = Council( - models=["grok"], synthesizer="claude", config=_config() - ) + council = Council(models=["grok"], synthesizer="claude", config=_config()) result = await council.ask("q") assert len(result.successful_answers) == 1 @@ -173,6 +167,7 @@ def handler(model, messages, **kwargs): async def test_no_members_available(monkeypatch, patch_call_model, clear_keys): """Zero available members yields an empty result, not an exception.""" + def handler(model, messages, **kwargs): # pragma: no cover - never called return make_response("unused") @@ -195,9 +190,7 @@ def handler(model, messages, **kwargs): patch_call_model(handler) - council = Council( - models=["grok", "gemini"], synthesizer="claude", config=_config() - ) + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) result = await council.ask("q") assert len(result.failed_answers) == 2 diff --git a/tests/test_modes.py b/tests/test_modes.py index 15f796e..9b8a68c 100644 --- a/tests/test_modes.py +++ b/tests/test_modes.py @@ -221,6 +221,7 @@ def handler(model, messages, **kwargs): async def test_debate_no_members_available(monkeypatch, patch_call_model, clear_keys): """Zero available members yields an empty debate result, not an exception.""" + def handler(model, messages, **kwargs): # pragma: no cover - never called return make_response("unused") @@ -372,9 +373,7 @@ def handler(model, messages, **kwargs): patch_call_model(handler) - council = Council( - models=["grok", "gemini"], synthesizer="claude", config=_config() - ) + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) result = await council.adversarial("q") adv = result.adversarial @@ -386,9 +385,7 @@ def handler(model, messages, **kwargs): assert result.synthesis is None -async def test_adversarial_proposer_no_key_falls_back( - monkeypatch, patch_call_model, clear_keys -): +async def test_adversarial_proposer_no_key_falls_back(monkeypatch, patch_call_model, clear_keys): """A requested proposer without a key falls back to the first available member.""" # Only gemini + claude have keys; requested proposer grok does not. monkeypatch.setenv("GEMINI_API_KEY", "dummy") @@ -432,9 +429,7 @@ def handler(model, messages, **kwargs): patch_call_model(handler) - council = Council( - models=["grok", "gemini"], synthesizer="claude", config=_config() - ) + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) result = await council.adversarial("q") adv = result.adversarial @@ -444,10 +439,9 @@ def handler(model, messages, **kwargs): assert "no API key" in adv.verdict_error -async def test_adversarial_no_members_available( - monkeypatch, patch_call_model, clear_keys -): +async def test_adversarial_no_members_available(monkeypatch, patch_call_model, clear_keys): """Zero available members yields an empty adversarial result, not an error.""" + def handler(model, messages, **kwargs): # pragma: no cover - never called return make_response("unused") @@ -478,9 +472,7 @@ def handler(model, messages, **kwargs): patch_call_model(handler) - council = Council( - models=["grok", "gemini"], synthesizer="claude", config=_config() - ) + council = Council(models=["grok", "gemini"], synthesizer="claude", config=_config()) result = council.adversarial_sync("hi") assert result.mode == "adversarial" diff --git a/tests/test_providers.py b/tests/test_providers.py index 0d97270..1d8beba 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -23,7 +23,6 @@ from conclave.config import ConclaveConfig, CustomEndpoint from conclave.providers import call_model - # --------------------------------------------------------------------------- # # Adapter registry # --------------------------------------------------------------------------- # @@ -33,17 +32,12 @@ def test_resolve_adapter_built_in_prefixes(): assert isinstance(resolve_adapter("openai/gpt-4.1"), OpenAICompatAdapter) assert isinstance(resolve_adapter("xai/grok-4.3"), OpenAICompatAdapter) assert isinstance(resolve_adapter("perplexity/sonar-pro"), OpenAICompatAdapter) - assert isinstance( - resolve_adapter("anthropic/claude-sonnet-4-6"), AnthropicAdapter - ) + assert isinstance(resolve_adapter("anthropic/claude-sonnet-4-6"), AnthropicAdapter) assert isinstance(resolve_adapter("gemini/gemini-2.5-pro"), GeminiAdapter) def test_resolve_adapter_per_provider_urls(): - assert ( - resolve_adapter("xai/grok-4.3").completions_url - == "https://api.x.ai/v1/chat/completions" - ) + assert resolve_adapter("xai/grok-4.3").completions_url == "https://api.x.ai/v1/chat/completions" # Perplexity has NO /v1 segment. assert ( resolve_adapter("perplexity/sonar-pro").completions_url @@ -125,9 +119,7 @@ async def boom(url, headers, json_body, timeout): monkeypatch.setattr("conclave.transport.post_json", boom) - answer = await call_model( - "openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}] - ) + answer = await call_model("openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}]) assert not answer.ok assert answer.answer is None assert "timed out" in answer.error @@ -138,9 +130,7 @@ async def test_call_model_missing_key_is_error(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") - answer = await call_model( - "openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}] - ) + answer = await call_model("openai", "openai/gpt-4.1", [{"role": "user", "content": "hi"}]) assert not answer.ok assert "OPENAI_API_KEY" in answer.error @@ -149,9 +139,7 @@ async def test_call_model_unknown_provider_is_error(monkeypatch): """An unknown provider prefix surfaces as a helpful, non-raising error.""" monkeypatch.setenv("CONCLAVE_CONFIG", "/nonexistent/conclave.yml") - answer = await call_model( - "mystery", "mystery/model", [{"role": "user", "content": "hi"}] - ) + answer = await call_model("mystery", "mystery/model", [{"role": "user", "content": "hi"}]) assert not answer.ok assert "unknown provider 'mystery'" in answer.error diff --git a/tests/test_registry_config.py b/tests/test_registry_config.py index d533879..8cc4436 100644 --- a/tests/test_registry_config.py +++ b/tests/test_registry_config.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pathlib import Path - from conclave.config import load_config from conclave.registry import ( DEFAULT_MODELS,