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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion greybeard/agents/slo_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ def _analyze_repo(self, repo_path: str) -> dict[str, Any]:

# Count files
py_files = list(path.glob("**/*.py"))
signals["file_count"] = len(py_files)
signals["file_count"] = len(py_files) # type: ignore[assignment]

# Look for monitoring/observability config
for f in path.glob("**/*"):
Expand Down
226 changes: 213 additions & 13 deletions greybeard/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,56 +6,187 @@
- ollama (local, llama3.2 or any model)
- lmstudio (local OpenAI-compatible server)


All backends except anthropic are accessed via the OpenAI-compatible API.
Anthropic uses its own SDK.
"""

from __future__ import annotations

import asyncio
import subprocess
import sys
from pathlib import Path

from rich.console import Console

from .config import GreybeardConfig, LLMConfig
from .groq_fallback import is_simple_task, run_groq
from .models import ReviewRequest
from .modes import build_system_prompt

# Token logging (best-effort — never crash the CLI if it fails)
try:
import os as _os
import sys as _sys

_modules_path = _os.environ.get("OPENCLAW_MODULES_PATH") or str(
Path.home() / ".openclaw" / "workspace" / "modules"
)
if _modules_path not in _sys.path:
_sys.path.insert(0, _modules_path)
from token_logger import log_usage as _log_usage
except Exception:

def _log_usage(**kwargs) -> None: # type: ignore[misc]
pass


console = Console()

MAX_INPUT_CHARS = 120_000 # ~30k tokens, warn above this


# ---------------------------------------------------------------------------
# Public entry point
# Public entry points
# ---------------------------------------------------------------------------


def run_review(
request: ReviewRequest,
config: GreybeardConfig | None = None,
config: GreybeardConfig | dict | None = None,
model_override: str | None = None,
stream: bool = True,
use_groq: bool | None = None,
) -> str:
"""Run the review and return the full response text."""
"""Run the review and return the full response text.

Args:
request: The ReviewRequest with mode, pack, and content.
config: GreybeardConfig object, dict, or None (loads from file).
When dict is passed, it's converted to GreybeardConfig.
model_override: Override the configured model.
stream: Whether to stream the response (default True).
use_groq: Force Groq (True), skip (False), or auto-detect (None).

Returns:
The full review response text.

If use_groq is None (default), auto-detect based on task complexity + config.
If use_groq is True, force Groq. If False, skip Groq entirely.
"""
if config is None:
config = GreybeardConfig.load()
elif isinstance(config, dict):
config = GreybeardConfig.from_dict(config)

llm = config.llm
model = model_override or llm.resolved_model()
system_prompt = build_system_prompt(request.mode, request.pack, request.audience)
user_message = _build_user_message(request)

groq_cfg = config.groq
# Determine whether to attempt Groq
should_try_groq = False
if use_groq is True:
should_try_groq = groq_cfg.available
elif use_groq is None:
should_try_groq = (
groq_cfg.available
and groq_cfg.use_for_simple_tasks
and is_simple_task(request.mode, user_message)
)

if should_try_groq:
try:
console.print("[dim]Routing to Groq (simple task)…[/dim]")
result, input_tok, output_tok = run_groq(
system_prompt=system_prompt,
user_message=user_message,
model=groq_cfg.model,
stream=stream,
api_key=groq_cfg.resolved_api_key(),
)
_log_usage(
agent="greybeard",
command="analyze",
pack=request.pack.name if request.pack else "",
mode=request.mode,
input_tokens=input_tok,
output_tokens=output_tok,
model=groq_cfg.model,
provider="groq",
)
console.print("[dim]via Groq ✓[/dim]")
return result
except RuntimeError as e:
console.print(f"[yellow]Groq unavailable ({e}), falling back to {llm.backend}[/yellow]")

# Primary backend
if llm.backend == "anthropic":
return _run_anthropic(llm, model, system_prompt, user_message, stream=stream)
result, input_tok, output_tok = _run_anthropic(
llm, model, system_prompt, user_message, stream=stream
)
elif llm.backend == "copilot":
result, input_tok, output_tok = _run_copilot(
llm, model, system_prompt, user_message, stream=stream
)
else:
return _run_openai_compat(llm, model, system_prompt, user_message, stream=stream)
result, input_tok, output_tok = _run_openai_compat(
llm, model, system_prompt, user_message, stream=stream
)

_log_usage(
agent="greybeard",
command="analyze",
pack=request.pack.name if request.pack else "",
mode=request.mode,
input_tokens=input_tok,
output_tokens=output_tok,
model=model,
provider=llm.backend,
)
provider_label = "via Anthropic" if llm.backend == "anthropic" else f"via {llm.backend}"
console.print(f"[dim]{provider_label} ✓[/dim]")
return result


async def run_review_async(
request: ReviewRequest,
config: GreybeardConfig | dict | None = None,
model_override: str | None = None,
stream: bool = False,
use_groq: bool | None = None,
) -> str:
"""Async wrapper for run_review (non-blocking for SaaS integrations).

Args:
request: The ReviewRequest with mode, pack, and content.
config: GreybeardConfig object, dict, or None (loads from file).
model_override: Override the configured model.
stream: Whether to stream the response (default False for async).
use_groq: Force Groq (True), skip (False), or auto-detect (None).

Returns:
The full review response text.

This wraps run_review in an executor to avoid blocking the event loop.
Ideal for web services, FastAPI endpoints, and serverless functions.
"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: run_review(
request=request,
config=config,
model_override=model_override,
stream=stream,
use_groq=use_groq,
),
)


# ---------------------------------------------------------------------------
# Backend implementations
# Backend implementations — return (text, input_tokens, output_tokens)
# ---------------------------------------------------------------------------


Expand All @@ -65,7 +196,7 @@ def _run_openai_compat(
system_prompt: str,
user_message: str,
stream: bool = True,
) -> str:
) -> tuple[str, int, int]:
"""Run via any OpenAI-compatible API (openai, ollama, lmstudio)."""
try:
from openai import OpenAI
Expand Down Expand Up @@ -95,14 +226,24 @@ def _run_openai_compat(
]

if stream:
return _stream_openai(client, model, messages)
text = _stream_openai(client, model, messages)
# Estimate tokens for streaming (no usage object)
input_tokens = len(system_prompt.split()) + len(user_message.split())
output_tokens = len(text.split())
return text, input_tokens, output_tokens
else:
resp = client.chat.completions.create(
model=model,
messages=messages, # type: ignore[arg-type]
stream=False,
)
return resp.choices[0].message.content or "" # type: ignore[union-attr]
text = resp.choices[0].message.content or "" # type: ignore[union-attr]
usage = resp.usage # type: ignore[union-attr]
return (
text,
(usage.prompt_tokens if usage else 0),
(usage.completion_tokens if usage else 0),
)


def _run_anthropic(
Expand All @@ -111,7 +252,7 @@ def _run_anthropic(
system_prompt: str,
user_message: str,
stream: bool = True,
) -> str:
) -> tuple[str, int, int]:
"""Run via Anthropic API."""
try:
import anthropic
Expand Down Expand Up @@ -143,16 +284,75 @@ def _run_anthropic(
for text in s.text_stream:
print(text, end="", flush=True)
full_text += text
# get_final_message() has usage counts
final = s.get_final_message()
input_tokens = final.usage.input_tokens if final.usage else 0
output_tokens = final.usage.output_tokens if final.usage else 0
print()
return full_text
return full_text, input_tokens, output_tokens
else:
resp = client.messages.create(
model=model,
max_tokens=4096,
system=system_prompt,
messages=[{"role": "user", "content": user_message}],
)
return str(resp.content[0].text)
return (
str(resp.content[0].text),
resp.usage.input_tokens,
resp.usage.output_tokens,
)


def _run_copilot(
llm: LLMConfig,
model: str,
system_prompt: str,
user_message: str,
stream: bool = True,
) -> tuple[str, int, int]:
"""Run via GitHub Copilot API (OpenAI-compatible endpoint)."""
try:
from openai import OpenAI
except ImportError:
print("Error: openai package not installed. Run: uv pip install openai", file=sys.stderr)
sys.exit(1)

api_key = llm.resolved_api_key()
if not api_key:
env_var = llm.resolved_api_key_env()
print(
f"Error: {env_var} is not set.\n"
f"Export it or add it to a .env file, or run: greybeard init",
file=sys.stderr,
)
sys.exit(1)

base_url = "https://api.githubcopilot.com/v1"
client = OpenAI(api_key=api_key, base_url=base_url)
messages: list[dict[str, str]] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
]

if stream:
text = _stream_openai(client, model, messages)
input_tokens = len(system_prompt.split()) + len(user_message.split())
output_tokens = len(text.split())
return text, input_tokens, output_tokens
else:
resp = client.chat.completions.create(
model=model,
messages=messages, # type: ignore[arg-type]
stream=False,
)
text = resp.choices[0].message.content or "" # type: ignore[union-attr]
usage = resp.usage # type: ignore[union-attr]
return (
text,
(usage.prompt_tokens if usage else 0),
(usage.completion_tokens if usage else 0),
)


def _stream_openai(client: object, model: str, messages: list[dict[str, str]]) -> str:
Expand Down
4 changes: 4 additions & 0 deletions greybeard/batch_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ def export_json(self, path: str | Path) -> None:
if not self.aggregated:
self.analyze()

assert self.aggregated is not None, "aggregated should be set after analyze()"

path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -451,6 +453,8 @@ def export_markdown(self, path: str | Path) -> None:
if not self.aggregated:
self.analyze()

assert self.aggregated is not None, "aggregated should be set after analyze()"

path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)

Expand Down
13 changes: 7 additions & 6 deletions greybeard/cli_slo.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from rich.table import Table

from .agents import SLOAgent
from .agents.slo_agent import SLORecommendation

console = Console()

Expand Down Expand Up @@ -90,15 +91,15 @@ def slo_check(context: tuple[str], repo: str | None, output: str, file: str | No
_output_table(recommendation)


def _output_json(rec: object) -> None:
def _output_json(rec: SLORecommendation) -> None:
"""Output as JSON."""
data = rec.to_dict() # type: ignore[union-attr]
data = rec.to_dict()
console.print(json.dumps(data, indent=2))


def _output_markdown(rec: object) -> None:
def _output_markdown(rec: SLORecommendation) -> None:
"""Output as Markdown."""
rec_dict = rec.to_dict() # type: ignore[union-attr]
rec_dict = rec.to_dict()

lines = [
f"# SLO Recommendations: {rec_dict['service_type'].upper()}",
Expand Down Expand Up @@ -132,9 +133,9 @@ def _output_markdown(rec: object) -> None:
console.print("\n".join(lines))


def _output_table(rec: object) -> None:
def _output_table(rec: SLORecommendation) -> None:
"""Output as a nice table."""
rec_dict = rec.to_dict() # type: ignore[union-attr]
rec_dict = rec.to_dict()

console.print(
Panel(
Expand Down
Loading