Skip to content

grip_hook.providers

grip_hook.providers

LLM providers.

A provider turns a diff into questions and answers into grades. Add a new one by implementing :class:grip_hook.providers.base.Provider and registering it in :data:REGISTRY.

REGISTRY module-attribute

REGISTRY: dict[str, ProviderFactory] = {'anthropic': _anthropic, 'openai': _openai, 'ollama': _openai, 'claude-code': _claude_code, 'claude': _claude_code, 'codex': _codex, 'gemini': _gemini, 'fake': _fake}

Provider name to factory.

ollama is openai with a local default URL; claude is an alias of claude-code. The agent providers shell out to a CLI that is already installed and signed in.

Provider

Bases: Protocol

Something that can write quiz questions and grade answers.

Source code in src/grip_hook/providers/base.py
@runtime_checkable
class Provider(Protocol):
    """Something that can write quiz questions and grade answers."""

    name: str
    """Short provider name shown in reports."""

    model: str
    """Model identifier shown in reports."""

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        ...

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one go."""
        ...

name instance-attribute

name: str

Short provider name shown in reports.

model instance-attribute

model: str

Model identifier shown in reports.

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/base.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    ...

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one go.

Source code in src/grip_hook/providers/base.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one go."""
    ...

get_provider

get_provider(cfg: Config) -> Provider

Instantiate the provider named by cfg.provider.

Source code in src/grip_hook/providers/__init__.py
def get_provider(cfg: Config) -> Provider:
    """Instantiate the provider named by ``cfg.provider``."""
    try:
        factory = REGISTRY[cfg.provider.lower()]
    except KeyError as exc:
        known = ", ".join(sorted(REGISTRY))
        raise ConfigError(f"unknown provider {cfg.provider!r}; known providers: {known}") from exc
    return factory(cfg)

grip_hook.providers.base

Provider interface.

Provider

Bases: Protocol

Something that can write quiz questions and grade answers.

Source code in src/grip_hook/providers/base.py
@runtime_checkable
class Provider(Protocol):
    """Something that can write quiz questions and grade answers."""

    name: str
    """Short provider name shown in reports."""

    model: str
    """Model identifier shown in reports."""

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        ...

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one go."""
        ...

name instance-attribute

name: str

Short provider name shown in reports.

model instance-attribute

model: str

Model identifier shown in reports.

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/base.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    ...

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one go.

Source code in src/grip_hook/providers/base.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one go."""
    ...

grip_hook.providers.anthropic

Anthropic provider built on the official anthropic SDK.

AnthropicProvider

Ask Claude for questions and grades using structured outputs.

Source code in src/grip_hook/providers/anthropic.py
class AnthropicProvider:
    """Ask Claude for questions and grades using structured outputs."""

    name = "anthropic"

    def __init__(self, cfg: Config, client: anthropic.Anthropic | None = None) -> None:
        self.model = cfg.model or DEFAULT_MODEL
        self._effort = cfg.effort.strip().lower()
        self._client = client or self._build_client(cfg)

    @staticmethod
    def _build_client(cfg: Config) -> anthropic.Anthropic:
        kwargs: dict[str, object] = {"timeout": cfg.timeout, "max_retries": 2}
        if cfg.api_key_env:
            key = os.environ.get(cfg.api_key_env)
            if not key:
                raise ProviderError(
                    f"api_key_env is set to {cfg.api_key_env!r} but that variable is empty"
                )
            kwargs["api_key"] = key
        if cfg.base_url:
            kwargs["base_url"] = cfg.base_url
        try:
            return anthropic.Anthropic(**kwargs)  # type: ignore[arg-type]
        except anthropic.AnthropicError as exc:
            raise ProviderError(
                "could not create the Anthropic client. Set ANTHROPIC_API_KEY or run "
                f"`ant auth login`. ({exc})"
            ) from exc

    def _output_config(self) -> dict[str, str] | None:
        # `effort` is accepted by Claude 4.5+ models; Haiku 4.5 and older reject it.
        if self._effort in {"", "none"} or self._effort not in _EFFORT_LEVELS:
            return None
        if self.model.startswith("claude-haiku"):
            return None
        return {"effort": self._effort}

    def _parse(self, system: str, user: str, schema: type[T]) -> T:
        kwargs: dict[str, object] = {}
        output_config = self._output_config()
        if output_config:
            kwargs["output_config"] = output_config
        try:
            response = self._client.messages.parse(
                model=self.model,
                max_tokens=_MAX_TOKENS,
                system=system,
                messages=[{"role": "user", "content": user}],
                output_format=schema,
                **kwargs,  # type: ignore[arg-type]
            )
        except anthropic.AuthenticationError as exc:
            raise ProviderError(
                "Anthropic rejected the API key. Set ANTHROPIC_API_KEY or run `ant auth login`."
            ) from exc
        except anthropic.NotFoundError as exc:
            raise ProviderError(f"model {self.model!r} was not found: {exc.message}") from exc
        except anthropic.RateLimitError as exc:
            raise ProviderError(
                f"Anthropic rate limit hit, try again shortly: {exc.message}"
            ) from exc
        except anthropic.APIStatusError as exc:
            raise ProviderError(f"Anthropic API error ({exc.status_code}): {exc.message}") from exc
        except anthropic.APIConnectionError as exc:
            raise ProviderError(f"could not reach the Anthropic API: {exc}") from exc

        if response.stop_reason == "refusal":
            detail = ""
            if response.stop_details is not None:
                detail = f" ({response.stop_details.category}: {response.stop_details.explanation})"
            raise ProviderError(f"the model declined to process this diff{detail}")
        if response.stop_reason == "max_tokens":
            raise ProviderError("the model's response was cut off; try a smaller diff")
        parsed = response.parsed_output
        if parsed is None:
            raise ProviderError("the model returned no structured output")
        return parsed

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._parse(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._parse(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/anthropic.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._parse(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/anthropic.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._parse(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

grip_hook.providers.openai_compat

Provider for any OpenAI-compatible chat completions API (OpenAI, Ollama, vLLM, ...).

Implemented with the standard library so grip carries no extra dependency for it. JSON output is requested through response_format with a JSON schema, which Ollama and most OpenAI-compatible servers honour; the reply is validated with the same pydantic models the Anthropic provider uses.

OpenAICompatibleProvider

Talk to {base_url}/chat/completions.

Source code in src/grip_hook/providers/openai_compat.py
class OpenAICompatibleProvider:
    """Talk to ``{base_url}/chat/completions``."""

    name = "openai"

    def __init__(self, cfg: Config, opener: Any = None) -> None:
        self.name = cfg.provider.lower()
        if not cfg.model:
            raise ProviderError(
                f"the {self.name} provider needs a model: set `model = ...` in .grip.toml"
            )
        self.model = cfg.model
        default_url = OLLAMA_URL if self.name == "ollama" else OPENAI_URL
        self._base_url = (cfg.base_url or default_url).rstrip("/")
        default_key_env = "OLLAMA_API_KEY" if self.name == "ollama" else "OPENAI_API_KEY"
        key_env = cfg.api_key_env or default_key_env
        self._api_key = os.environ.get(key_env, "")
        if not self._api_key and self.name != "ollama":
            raise ProviderError(f"{key_env} is not set; the {self.name} provider needs an API key")
        self._timeout = cfg.timeout
        self._opener = opener or urllib.request.urlopen

    def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        headers = {"Content-Type": "application/json", "User-Agent": "grip-hook"}
        if self._api_key:
            headers["Authorization"] = f"Bearer {self._api_key}"
        request = urllib.request.Request(
            f"{self._base_url}/chat/completions", data=body, headers=headers, method="POST"
        )
        try:
            with self._opener(request, timeout=self._timeout) as resp:
                raw = resp.read()
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "replace")[:500]
            raise ProviderError(f"{self.name} API error ({exc.code}): {detail}") from exc
        except urllib.error.URLError as exc:
            raise ProviderError(f"could not reach {self._base_url}: {exc.reason}") from exc
        try:
            data: dict[str, Any] = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ProviderError(f"{self.name} returned invalid JSON") from exc
        return data

    def _complete(self, system: str, user: str, schema: type[T]) -> T:
        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
            "response_format": {
                "type": "json_schema",
                "json_schema": {
                    "name": schema.__name__,
                    "schema": schema.model_json_schema(),
                },
            },
        }
        data = self._post(payload)
        try:
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, TypeError) as exc:
            raise ProviderError(
                f"unexpected response shape from {self.name}: {data!r}"[:500]
            ) from exc
        try:
            return schema.model_validate_json(content)
        except ValidationError as exc:
            raise ProviderError(
                f"{self.name} returned output that does not match the schema: {exc}"
            ) from exc

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._complete(
            QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet
        )

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._complete(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/openai_compat.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._complete(
        QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet
    )

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/openai_compat.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._complete(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

grip_hook.providers.agents

Providers that reuse a coding agent CLI already installed and signed in.

No API key is needed: each provider shells out to the agent in non-interactive mode, sends the same prompts the API providers use, and asks for JSON back.

  • claude-code: claude -p with --json-schema (schema-enforced output).
  • codex: codex exec with --output-schema (schema-enforced output).
  • gemini: gemini --output-format json; the JSON is requested in the prompt and extracted from the reply.

Every agent runs with tools disabled where the CLI allows it, in an empty scratch directory, so it cannot read the repository, run commands, or pick up project-level instructions and hooks. It only ever sees the diff and the answers.

AgentCLIProvider

Common machinery: build a command, run it, validate the JSON it returns.

Source code in src/grip_hook/providers/agents.py
class AgentCLIProvider:
    """Common machinery: build a command, run it, validate the JSON it returns."""

    name: str = "agent"
    executable: ClassVar[str] = ""
    install_hint: ClassVar[str] = ""
    enforces_schema: ClassVar[bool] = False
    """Whether the CLI constrains its output to the schema; otherwise it is asked for."""
    system_flag: ClassVar[str] = ""
    """Flag that takes the system prompt; empty means prepend it to the user prompt."""

    def __init__(self, cfg: Config) -> None:
        self.model = cfg.model
        self._timeout = cfg.timeout
        self._binary = shutil.which(self.executable)

    # -- to implement per agent -------------------------------------------------------

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """The argv to run. The prompt is written to stdin."""
        raise NotImplementedError

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Turn the process output into the JSON payload (a dict)."""
        raise NotImplementedError

    # -- shared -----------------------------------------------------------------------

    def _prompt(self, system: str, user: str, schema: dict[str, Any]) -> str:
        parts = [user.strip()] if self.system_flag else [system.strip(), user.strip()]
        if not self.enforces_schema:
            parts.append(_JSON_INSTRUCTION + json.dumps(schema))
        return "\n\n".join(parts) + "\n"

    def _run(self, system: str, user: str, output: type[T]) -> T:
        if self._binary is None:
            raise ProviderError(
                f"{self.executable!r} was not found on PATH. {self.install_hint} "
                "Or pick another provider in .grip.toml."
            )
        schema = _schema(output)
        prompt = self._prompt(system, user, schema)
        with tempfile.TemporaryDirectory(prefix="grip-agent-") as tmp:
            workdir = Path(tmp)
            cmd = [self._binary, *self.command(schema, workdir)]
            if self.system_flag:
                cmd += [self.system_flag, system.strip()]
            try:
                proc = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    text=True,
                    encoding="utf-8",
                    errors="replace",
                    timeout=self._timeout,
                    cwd=workdir,
                    env=os.environ.copy(),
                    check=False,
                )
            except subprocess.TimeoutExpired as exc:
                raise ProviderError(
                    f"{self.executable} did not answer within {self._timeout:.0f}s "
                    "(raise `timeout` in .grip.toml)"
                ) from exc
            except OSError as exc:
                raise ProviderError(f"could not run {self.executable}: {exc}") from exc
            if proc.returncode != 0:
                detail = (proc.stderr or proc.stdout).strip()[-600:]
                raise ProviderError(f"{self.executable} exited with {proc.returncode}: {detail}")
            payload = self.parse_output(proc.stdout, workdir)
        try:
            return output.model_validate(payload)
        except ValidationError as exc:
            raise ProviderError(
                f"{self.executable} returned output that does not match the schema: {exc}"
            ) from exc

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._run(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._run(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

enforces_schema class-attribute

enforces_schema: bool = False

Whether the CLI constrains its output to the schema; otherwise it is asked for.

system_flag class-attribute

system_flag: str = ''

Flag that takes the system prompt; empty means prepend it to the user prompt.

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

The argv to run. The prompt is written to stdin.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """The argv to run. The prompt is written to stdin."""
    raise NotImplementedError

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Turn the process output into the JSON payload (a dict).

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Turn the process output into the JSON payload (a dict)."""
    raise NotImplementedError

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/agents.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._run(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/agents.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._run(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

ClaudeCodeProvider

Bases: AgentCLIProvider

Claude Code (claude) in print mode with schema-enforced structured output.

Source code in src/grip_hook/providers/agents.py
class ClaudeCodeProvider(AgentCLIProvider):
    """Claude Code (``claude``) in print mode with schema-enforced structured output."""

    name = "claude-code"
    executable = "claude"
    install_hint = "Install it with `npm install -g @anthropic-ai/claude-code` and sign in."
    enforces_schema = True
    system_flag = "--system-prompt"

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """``claude -p`` with no tools, no session persistence, JSON envelope output."""
        cmd = [
            "-p",
            "--output-format",
            "json",
            "--json-schema",
            json.dumps(schema),
            "--tools",
            "",
            "--no-session-persistence",
        ]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Read ``structured_output`` from the JSON envelope."""
        try:
            envelope = json.loads(stdout)
        except json.JSONDecodeError as exc:
            raise ProviderError("claude returned an unreadable envelope") from exc
        if envelope.get("is_error"):
            raise ProviderError(f"claude reported an error: {envelope.get('result', '')}")
        structured = envelope.get("structured_output")
        if structured is not None:
            return structured
        return extract_json(str(envelope.get("result", "")))

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

claude -p with no tools, no session persistence, JSON envelope output.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """``claude -p`` with no tools, no session persistence, JSON envelope output."""
    cmd = [
        "-p",
        "--output-format",
        "json",
        "--json-schema",
        json.dumps(schema),
        "--tools",
        "",
        "--no-session-persistence",
    ]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Read structured_output from the JSON envelope.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Read ``structured_output`` from the JSON envelope."""
    try:
        envelope = json.loads(stdout)
    except json.JSONDecodeError as exc:
        raise ProviderError("claude returned an unreadable envelope") from exc
    if envelope.get("is_error"):
        raise ProviderError(f"claude reported an error: {envelope.get('result', '')}")
    structured = envelope.get("structured_output")
    if structured is not None:
        return structured
    return extract_json(str(envelope.get("result", "")))

CodexProvider

Bases: AgentCLIProvider

OpenAI Codex CLI (codex exec) with --output-schema.

Source code in src/grip_hook/providers/agents.py
class CodexProvider(AgentCLIProvider):
    """OpenAI Codex CLI (``codex exec``) with ``--output-schema``."""

    name = "codex"
    executable = "codex"
    install_hint = "Install it with `npm install -g @openai/codex` and run `codex login`."
    enforces_schema = True

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """Non-interactive, read-only sandbox, last message written to a file."""
        schema_file = workdir / "schema.json"
        schema_file.write_text(json.dumps(schema), "utf-8")
        cmd = [
            "exec",
            "-",
            "--skip-git-repo-check",
            "--sandbox",
            "read-only",
            "--output-schema",
            str(schema_file),
            "--output-last-message",
            str(workdir / "last-message.txt"),
        ]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Prefer the last-message file; fall back to scanning stdout."""
        last = workdir / "last-message.txt"
        text = last.read_text("utf-8") if last.exists() else stdout
        return extract_json(text)

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

Non-interactive, read-only sandbox, last message written to a file.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """Non-interactive, read-only sandbox, last message written to a file."""
    schema_file = workdir / "schema.json"
    schema_file.write_text(json.dumps(schema), "utf-8")
    cmd = [
        "exec",
        "-",
        "--skip-git-repo-check",
        "--sandbox",
        "read-only",
        "--output-schema",
        str(schema_file),
        "--output-last-message",
        str(workdir / "last-message.txt"),
    ]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Prefer the last-message file; fall back to scanning stdout.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Prefer the last-message file; fall back to scanning stdout."""
    last = workdir / "last-message.txt"
    text = last.read_text("utf-8") if last.exists() else stdout
    return extract_json(text)

GeminiProvider

Bases: AgentCLIProvider

Gemini CLI (gemini) in non-interactive JSON mode.

Source code in src/grip_hook/providers/agents.py
class GeminiProvider(AgentCLIProvider):
    """Gemini CLI (``gemini``) in non-interactive JSON mode."""

    name = "gemini"
    executable = "gemini"
    install_hint = "Install it with `npm install -g @google/gemini-cli` and sign in."
    enforces_schema = False

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """Prompt comes from stdin; the reply is a JSON envelope with a ``response`` key."""
        cmd = ["--output-format", "json"]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Unwrap the envelope, then extract the JSON object from the reply text."""
        try:
            envelope = json.loads(stdout)
        except json.JSONDecodeError:
            return extract_json(stdout)
        if isinstance(envelope, dict):
            if "error" in envelope:
                err = envelope["error"]
                message = err.get("message", err) if isinstance(err, dict) else err
                raise ProviderError(f"gemini reported an error: {message}")
            if "response" in envelope:
                return extract_json(str(envelope["response"]))
        return envelope

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

Prompt comes from stdin; the reply is a JSON envelope with a response key.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """Prompt comes from stdin; the reply is a JSON envelope with a ``response`` key."""
    cmd = ["--output-format", "json"]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Unwrap the envelope, then extract the JSON object from the reply text.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Unwrap the envelope, then extract the JSON object from the reply text."""
    try:
        envelope = json.loads(stdout)
    except json.JSONDecodeError:
        return extract_json(stdout)
    if isinstance(envelope, dict):
        if "error" in envelope:
            err = envelope["error"]
            message = err.get("message", err) if isinstance(err, dict) else err
            raise ProviderError(f"gemini reported an error: {message}")
        if "response" in envelope:
            return extract_json(str(envelope["response"]))
    return envelope

extract_json

extract_json(text: str) -> Any

Parse the first JSON object in text, tolerating code fences and chatter.

Source code in src/grip_hook/providers/agents.py
def extract_json(text: str) -> Any:
    """Parse the first JSON object in ``text``, tolerating code fences and chatter."""
    text = text.strip()
    if text.startswith("```"):
        text = text.strip("`")
        if text.startswith("json"):
            text = text[4:]
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end <= start:
        raise ProviderError("the agent did not return a JSON object")
    try:
        return json.loads(text[start : end + 1])
    except json.JSONDecodeError as exc:
        raise ProviderError(f"the agent returned invalid JSON: {exc}") from exc

grip_hook.providers.fake

A deterministic provider for tests, demos and grip quiz --provider fake.

Two modes:

  • Default. Questions are derived from the changed file names; an answer scores full marks when it mentions the word because or is at least twenty characters long, half marks when it is non-empty, and zero otherwise.
  • Scripted. When GRIP_FAKE_SCRIPT points to a JSON file, questions, grading rules and verdicts come from that file (see :func:load_script). This is how the demo recording and some tests get realistic, reproducible output without a network.

GRIP_FAKE_DELAY (seconds, float) makes each call sleep, so the spinner is visible.

GradeRule

Bases: BaseModel

Award score when the lower-cased answer contains match.

Source code in src/grip_hook/providers/fake.py
class GradeRule(BaseModel):
    """Award ``score`` when the lower-cased answer contains ``match``."""

    match: str
    score: int = Field(ge=0, le=POINTS_PER_QUESTION)
    feedback: str

DefaultGrade

Bases: BaseModel

Grade used when no rule matches.

Source code in src/grip_hook/providers/fake.py
class DefaultGrade(BaseModel):
    """Grade used when no rule matches."""

    score: int = Field(default=0, ge=0, le=POINTS_PER_QUESTION)
    feedback: str = "No answer, or nothing that shows understanding of the change."

ScriptedQuestion

Bases: Question

A question plus the rules used to grade answers to it.

Source code in src/grip_hook/providers/fake.py
class ScriptedQuestion(Question):
    """A question plus the rules used to grade answers to it."""

    rules: list[GradeRule] = Field(default_factory=list)
    default: DefaultGrade = Field(default_factory=DefaultGrade)

    def grade(self, answer: str) -> tuple[int, str]:
        """First matching rule wins; rules are checked in order."""
        text = answer.lower()
        for rule in self.rules:
            if rule.match.lower() in text:
                return rule.score, rule.feedback
        return self.default.score, self.default.feedback

grade

grade(answer: str) -> tuple[int, str]

First matching rule wins; rules are checked in order.

Source code in src/grip_hook/providers/fake.py
def grade(self, answer: str) -> tuple[int, str]:
    """First matching rule wins; rules are checked in order."""
    text = answer.lower()
    for rule in self.rules:
        if rule.match.lower() in text:
            return rule.score, rule.feedback
    return self.default.score, self.default.feedback

Verdict

Bases: BaseModel

Verdict text for totals at or above min.

Source code in src/grip_hook/providers/fake.py
class Verdict(BaseModel):
    """Verdict text for totals at or above ``min``."""

    min: int = Field(ge=0, le=100)
    text: str

Script

Bases: BaseModel

The scripted provider's data file.

Source code in src/grip_hook/providers/fake.py
class Script(BaseModel):
    """The scripted provider's data file."""

    summary: str
    questions: list[ScriptedQuestion] = Field(min_length=QUESTION_COUNT, max_length=QUESTION_COUNT)
    verdicts: list[Verdict] = Field(default_factory=list)

    def verdict_for(self, total: int) -> str:
        """Pick the verdict with the highest ``min`` not above ``total``."""
        eligible = [v for v in self.verdicts if v.min <= total]
        if not eligible:
            return f"Scripted verdict: {total} points."
        return max(eligible, key=lambda v: v.min).text

verdict_for

verdict_for(total: int) -> str

Pick the verdict with the highest min not above total.

Source code in src/grip_hook/providers/fake.py
def verdict_for(self, total: int) -> str:
    """Pick the verdict with the highest ``min`` not above ``total``."""
    eligible = [v for v in self.verdicts if v.min <= total]
    if not eligible:
        return f"Scripted verdict: {total} points."
    return max(eligible, key=lambda v: v.min).text

FakeProvider

Offline stand-in for a real model.

Source code in src/grip_hook/providers/fake.py
class FakeProvider:
    """Offline stand-in for a real model."""

    name = "fake"

    def __init__(self, cfg: Config) -> None:
        self.model = cfg.model or "fake"
        script_path = os.environ.get(SCRIPT_ENV)
        self.script = load_script(Path(script_path)) if script_path else None
        try:
            self.delay = float(os.environ.get(DELAY_ENV, "0") or 0)
        except ValueError:
            self.delay = 0.0

    def _pause(self) -> None:
        if self.delay > 0:
            time.sleep(self.delay)

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Produce five questions, scripted or templated over the changed files."""
        self._pause()
        if self.script is not None:
            questions = [
                Question(question=q.question, rubric=q.rubric, focus=q.focus)
                for q in self.script.questions
            ]
            return QuestionSet(summary=self.script.summary, questions=questions)
        files = list(diff.files) or ["the diff"]
        questions = [
            Question(
                question=template.format(file=files[i % len(files)]),
                rubric="Any specific, non-empty explanation.",
                focus=focus,
            )
            for i, (focus, template) in enumerate(_TEMPLATES[:QUESTION_COUNT])
        ]
        return QuestionSet(
            summary=(
                f"Fake summary of {len(files)} changed file(s) at {difficulty.value} difficulty."
            ),
            questions=questions,
        )

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Score answers deterministically, by script rules or by length."""
        self._pause()
        by_index = {a.question_index: a.text.strip() for a in answers}
        grades = []
        for i in range(len(questions)):
            text = by_index.get(i, "")
            if self.script is not None:
                score, feedback = self.script.questions[i].grade(text)
            elif not text:
                score, feedback = 0, "No answer given."
            elif "because" in text.lower() or len(text) >= 20:
                score, feedback = POINTS_PER_QUESTION, "Specific and complete."
            else:
                score, feedback = POINTS_PER_QUESTION // 2, "Too short to show understanding."
            grades.append(QuestionGrade(question_index=i, score=score, feedback=feedback))
        total = sum(g.score for g in grades)
        if self.script is not None:
            return GradeSheet(grades=grades, verdict=self.script.verdict_for(total))
        return GradeSheet(grades=grades, verdict=f"Fake verdict: {total} points.")

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Produce five questions, scripted or templated over the changed files.

Source code in src/grip_hook/providers/fake.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Produce five questions, scripted or templated over the changed files."""
    self._pause()
    if self.script is not None:
        questions = [
            Question(question=q.question, rubric=q.rubric, focus=q.focus)
            for q in self.script.questions
        ]
        return QuestionSet(summary=self.script.summary, questions=questions)
    files = list(diff.files) or ["the diff"]
    questions = [
        Question(
            question=template.format(file=files[i % len(files)]),
            rubric="Any specific, non-empty explanation.",
            focus=focus,
        )
        for i, (focus, template) in enumerate(_TEMPLATES[:QUESTION_COUNT])
    ]
    return QuestionSet(
        summary=(
            f"Fake summary of {len(files)} changed file(s) at {difficulty.value} difficulty."
        ),
        questions=questions,
    )

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Score answers deterministically, by script rules or by length.

Source code in src/grip_hook/providers/fake.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Score answers deterministically, by script rules or by length."""
    self._pause()
    by_index = {a.question_index: a.text.strip() for a in answers}
    grades = []
    for i in range(len(questions)):
        text = by_index.get(i, "")
        if self.script is not None:
            score, feedback = self.script.questions[i].grade(text)
        elif not text:
            score, feedback = 0, "No answer given."
        elif "because" in text.lower() or len(text) >= 20:
            score, feedback = POINTS_PER_QUESTION, "Specific and complete."
        else:
            score, feedback = POINTS_PER_QUESTION // 2, "Too short to show understanding."
        grades.append(QuestionGrade(question_index=i, score=score, feedback=feedback))
    total = sum(g.score for g in grades)
    if self.script is not None:
        return GradeSheet(grades=grades, verdict=self.script.verdict_for(total))
    return GradeSheet(grades=grades, verdict=f"Fake verdict: {total} points.")

load_script

load_script(path: Path) -> Script

Read and validate a script file.

Source code in src/grip_hook/providers/fake.py
def load_script(path: Path) -> Script:
    """Read and validate a script file."""
    try:
        data: Any = json.loads(path.read_text("utf-8"))
        return Script.model_validate(data)
    except (OSError, json.JSONDecodeError, ValidationError) as exc:
        raise ProviderError(f"could not load {SCRIPT_ENV}={path}: {exc}") from exc