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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 3 additions & 4 deletions src/conclave/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 3 additions & 9 deletions src/conclave/adapters/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 3 additions & 7 deletions src/conclave/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]"

Expand Down Expand Up @@ -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:
Expand Down
16 changes: 4 additions & 12 deletions src/conclave/adapters/gemini.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand All @@ -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)")
Expand All @@ -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
Expand Down
14 changes: 4 additions & 10 deletions src/conclave/adapters/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@

from __future__ import annotations

from typing import Optional

from ..models import TokenUsage
from .base import ProviderError

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -82,19 +80,15 @@ 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`.
"""
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"]
Expand All @@ -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
Expand Down
22 changes: 7 additions & 15 deletions src/conclave/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from __future__ import annotations

import json
from typing import Optional

import typer
from rich.console import Console
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
)
)
Expand Down Expand Up @@ -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",
Expand All @@ -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)

Expand All @@ -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)

Expand All @@ -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:
Expand Down
5 changes: 1 addition & 4 deletions src/conclave/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()))

Expand Down
Loading
Loading