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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ uv run pytest
uv run gitmate --help
uv run gitmate config set-key # stored in the OS credential store, never in a file
uv run gitmate config show
uv run gitmate config set model gemini-flash
uv run gitmate config set model gemini-3.5-flash-lite
uv run gitmate config set ignore_globs '*.gen.py,*.tmp' # comma-separated, empty clears
uv run gitmate config set max_context_tokens 8000 # override model context limit
uv run gitmate config set reserved_output_tokens 1500 # reserve headroom for model reply
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies = [
"tomli-w>=1.0",
"diskcache>=5.6",
"tenacity>=8.2",
"httpx>=0.27",
"google-genai>=0.3", # default provider (Gemini 3 Flash / Flash-Lite)
"anthropic>=0.34", # secondary provider, same LLMProvider interface
]
Expand Down
74 changes: 53 additions & 21 deletions src/gitmate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import dataclasses

import typer
from rich.console import Console
from rich.table import Table
Expand Down Expand Up @@ -123,38 +125,68 @@ def config_show() -> None:
table.add_column("field")
table.add_column("value")
table.add_row("config file", str(config_mod.config_path()))
table.add_row("model", cfg.model)
table.add_row("commit_style", cfg.commit_style)
table.add_row(
"budget_cap_usd", str(cfg.budget_cap_usd) if cfg.budget_cap_usd is not None else "none"
)
table.add_row("ignore_globs", ", ".join(cfg.ignore_globs) if cfg.ignore_globs else "none")
for f in dataclasses.fields(cfg):
val = getattr(cfg, f.name)
if val is None:
display_val = "none"
elif isinstance(val, list):
display_val = ", ".join(val) if val else "none"
else:
display_val = str(val)
table.add_row(f.name, display_val)
table.add_row("api_key", "set" if key_set else "not set")
console.print(table)


@config_app.command("set")
def config_set(field: str, value: str) -> None:
"""Set one field: model, commit_style, budget_cap_usd, ignore_globs."""
"""Set any config field: model, commit_style, budget_cap_usd, ignore_globs, cache_dir, etc."""
try:
cfg = config_mod.load_config()
except config_mod.ConfigError as exc:
console.print(f"[red]error:[/red] {exc}")
raise typer.Exit(1) from None
match field:
case "model":
cfg.model = value
case "commit_style":
cfg.commit_style = value
case "budget_cap_usd":
cfg.budget_cap_usd = None if value == "" else _parse_budget(value)
case "ignore_globs":
cfg.ignore_globs = [g.strip() for g in value.split(",") if g.strip()]
case _:
raise typer.BadParameter(
f"unknown field {field!r}; expected model, commit_style, "
"budget_cap_usd, or ignore_globs."
)

field_map = {f.name: f for f in dataclasses.fields(config_mod.GitmateConfig)}
if field not in field_map:
valid_fields = ", ".join(field_map.keys())
raise typer.BadParameter(f"unknown field {field!r}; expected one of: {valid_fields}.")

if field == "ignore_globs":
cfg.ignore_globs = [g.strip() for g in value.split(",") if g.strip()]
elif field == "budget_cap_usd":
cfg.budget_cap_usd = None if value in ("", "none") else _parse_budget(value)
elif field in ("max_context_tokens", "reserved_output_tokens", "template_overhead"):
if field == "max_context_tokens" and value in ("", "none"):
cfg.max_context_tokens = None
else:
try:
int_val = int(value)
if int_val <= 0:
raise ValueError
except ValueError:
raise typer.BadParameter(f"'{field}' must be a positive integer.") from None
setattr(cfg, field, int_val)
elif field == "cache_dir":
cfg.cache_dir = None if value in ("", "none") else value.strip() or None
elif field in ("model", "commit_style"):
stripped = value.strip()
if not stripped:
raise typer.BadParameter(f"'{field}' must not be empty.")
setattr(cfg, field, stripped)
else:
setattr(cfg, field, value)

# Validate overall config integrity
max_ctx = cfg.max_context_tokens
reserved = cfg.reserved_output_tokens
overhead = cfg.template_overhead
if max_ctx is not None and max_ctx <= (reserved + overhead):
raise typer.BadParameter(
f"'max_context_tokens' ({max_ctx}) must be greater than "
f"'reserved_output_tokens' + 'template_overhead' ({reserved + overhead})."
)

config_mod.save_config(cfg)
console.print(f"[green]Set {field}.[/green]")

Expand Down
23 changes: 19 additions & 4 deletions src/gitmate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@
API_KEY_ACCOUNT = "api-key"
CONFIG_ENV_VAR = "GITMATE_CONFIG_DIR"

# Opaque default; exact provider model IDs are verified in Phase 3.
DEFAULT_MODEL = "gemini-flash"
# Default model ID. This is the literal google-genai SDK identifier. Gemini
# model IDs churn fast (2.0 Flash is retired; Flash-Lite previews deprecate
# within months), so keep this configurable and re-check before assuming it
# still resolves.
DEFAULT_MODEL = "gemini-3.5-flash-lite"
DEFAULT_COMMIT_STYLE = "conventional"

#: Context window limits for known models; users override via `max_context_tokens`.
DEFAULT_MODEL_CONTEXT_WINDOWS: dict[str, int] = {
"gemini-flash": 1_048_576,
"gemini-3-flash": 1_048_576,
"gemini-3.5-flash-lite": 1_048_576,
"gemini-3-flash": 1_048_576,
"gemini-flash": 1_048_576,
"claude-3-5-sonnet": 200_000,
"claude-3-5-haiku": 200_000,
}
Expand All @@ -51,6 +54,7 @@ class GitmateConfig:
max_context_tokens: int | None = None
reserved_output_tokens: int = DEFAULT_RESERVED_OUTPUT_TOKENS
template_overhead: int = DEFAULT_TEMPLATE_OVERHEAD
cache_dir: str | None = None


class SecretStore(Protocol):
Expand Down Expand Up @@ -175,6 +179,14 @@ def load_config(path: Path | None = None) -> GitmateConfig:
f"than 'reserved_output_tokens' + 'template_overhead' ({reserved + overhead})"
)

cache_dir_raw = raw.get("cache_dir")
if cache_dir_raw is None:
cache_dir: str | None = None
elif not isinstance(cache_dir_raw, str) or not cache_dir_raw:
raise ConfigError(f"cannot parse {resolved}: 'cache_dir' must be a non-empty string")
else:
cache_dir = cache_dir_raw

return GitmateConfig(
model=model,
commit_style=style,
Expand All @@ -183,6 +195,7 @@ def load_config(path: Path | None = None) -> GitmateConfig:
max_context_tokens=max_ctx,
reserved_output_tokens=reserved,
template_overhead=overhead,
cache_dir=cache_dir,
)


Expand All @@ -201,4 +214,6 @@ def save_config(cfg: GitmateConfig, path: Path | None = None) -> None:
data["reserved_output_tokens"] = cfg.reserved_output_tokens
if cfg.template_overhead != DEFAULT_TEMPLATE_OVERHEAD:
data["template_overhead"] = cfg.template_overhead
if cfg.cache_dir is not None:
data["cache_dir"] = cfg.cache_dir
resolved.write_text(tomli_w.dumps(data), encoding="utf-8")
33 changes: 33 additions & 0 deletions src/gitmate/providers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Provider abstraction: LLMProvider protocol, response types, errors."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Protocol, runtime_checkable


class ProviderUnavailable(Exception):
"""Raised when the LLM backend cannot produce a result after retries."""


class RetryableProviderError(Exception):
"""Internal marker for transient failures worth retrying before giving up."""


@dataclass
class LLMResponse:
"""Text plus optional token usage from one provider call."""

text: str
model: str
input_tokens: int | None = None
output_tokens: int | None = None


@runtime_checkable
class LLMProvider(Protocol):
"""Generates text; every backend can be swapped behind this boundary."""

def generate(self, prompt: str, model: str) -> LLMResponse:
"""Produce a response for the prompt using the given model."""
...
63 changes: 63 additions & 0 deletions src/gitmate/providers/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Caching wrapper that short-circuits provider calls on a hash key."""

from __future__ import annotations

import hashlib
import os
from pathlib import Path

from diskcache import Cache # type: ignore[import-untyped]

from gitmate.providers.base import LLMProvider, LLMResponse

#: Bumped in Phase 4 when real prompt templates land; kept separate so those
#: templates can thread the same key without migration. Different templates =
#: different (and intentionally colder) cache entries.
DEFAULT_TEMPLATE_VERSION = "1"


def default_cache_dir() -> Path:
"""XDG cache dir for gitmate, overridable via config.cache_dir."""
override = os.environ.get("GITMATE_CACHE_DIR")
if override:
return Path(override)
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
return Path(base) / "gitmate"


def cache_key(prompt: str, model: str, template_version: str) -> str:
"""Stable hash of the inputs that change a response's correctness."""
material = f"{template_version}\x00{model}\x00{prompt}"
return hashlib.sha256(material.encode("utf-8")).hexdigest()


class CachedProvider:
"""LLMProvider that consults diskcache before calling the wrapped provider."""

def __init__(
self,
provider: LLMProvider,
cache_dir: Path | str | None = None,
ttl: float | None = None,
template_version: str = DEFAULT_TEMPLATE_VERSION,
) -> None:
self._provider = provider
resolved = str(cache_dir) if cache_dir is not None else str(default_cache_dir())
self._cache = Cache(resolved)
self._ttl = ttl
self._template_version = template_version

def generate(self, prompt: str, model: str) -> LLMResponse:
"""Return a cached response when present, else generate and store."""
key = cache_key(prompt, model, self._template_version)
cached: LLMResponse | None = self._cache.get(key, default=None)
if cached is not None:
return cached
response = self._provider.generate(prompt, model)
if response.text.strip():
self._cache.set(key, response, expire=self._ttl)
return response

def close(self) -> None:
"""Close the backing cache."""
self._cache.close()
84 changes: 84 additions & 0 deletions src/gitmate/providers/gemini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""Gemini provider over the google-genai SDK, with retry on transient errors."""

from __future__ import annotations

from typing import TYPE_CHECKING

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential

from gitmate.providers.base import LLMResponse, ProviderUnavailable, RetryableProviderError

if TYPE_CHECKING:
from google import genai


def _is_transient(exc: BaseException) -> bool:
"""True for failures worth retrying: 5xx, 429 rate limits, transport."""
from google.genai.errors import ClientError, ServerError

if isinstance(exc, ServerError):
return True
if isinstance(exc, ClientError):
return getattr(exc, "code", None) == 429
import httpx

return isinstance(exc, httpx.TransportError)


class GeminiProvider:
"""LLMProvider backed by Google's Gemini models via google-genai."""

def __init__(self, api_key: str | None = None, client: genai.Client | None = None) -> None:
self._client = client
self._api_key = api_key

def _get_client(self) -> genai.Client:
if self._client is not None:
return self._client
if not self._api_key:
raise ProviderUnavailable(
"Gemini API key is required. Run 'gitmate config set-key' first."
)
from google import genai

self._client = genai.Client(api_key=self._api_key)
return self._client

def generate(self, prompt: str, model: str) -> LLMResponse:
"""Generate text for prompt, retrying transient failures then raising."""
try:
return self._generate_with_retry(self._get_client(), prompt, model)
except RetryableProviderError as exc:
raise ProviderUnavailable(f"Gemini unavailable after retries: {exc}") from exc

@retry(
retry=retry_if_exception_type(RetryableProviderError),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
)
def _generate_with_retry(self, client: genai.Client, prompt: str, model: str) -> LLMResponse:
try:
resp = client.models.generate_content(model=model, contents=prompt)
except Exception as exc:
if _is_transient(exc):
raise RetryableProviderError(str(exc)) from exc
raise ProviderUnavailable(f"Gemini request failed: {exc}") from exc
text = (resp.text or "").strip()
if not text:
finish_reason = None
candidates = getattr(resp, "candidates", None)
if candidates and len(candidates) > 0:
finish_reason = getattr(candidates[0], "finish_reason", None)
if finish_reason is not None:
reason_name = getattr(finish_reason, "name", str(finish_reason))
raise ProviderUnavailable(
f"Gemini declined to respond (finish_reason: {reason_name})."
)
raise ProviderUnavailable("Gemini returned an empty response.")
return LLMResponse(
text=text,
model=model,
input_tokens=getattr(resp.usage_metadata, "prompt_token_count", None),
output_tokens=getattr(resp.usage_metadata, "candidates_token_count", None),
)
17 changes: 17 additions & 0 deletions src/gitmate/token_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
DEFAULT_MODEL,
DEFAULT_RESERVED_OUTPUT_TOKENS,
DEFAULT_TEMPLATE_OVERHEAD,
GitmateConfig,
get_context_window,
)
from gitmate.diff_extractor import FileDiff

Expand Down Expand Up @@ -166,6 +168,21 @@ def __init__(
self.reserved_output_tokens = reserved_output_tokens
self.default_template_overhead = default_template_overhead

@classmethod
def from_config(cls, cfg: GitmateConfig, counter: TokenCounter) -> TokenBudgetManager:
"""Build a manager whose context window comes from the configured model.

Replaces the constructor's 1M default so non-Gemini models (e.g. a
200k Claude window) are never silently over-budgeted.
"""
return cls(
counter=counter,
model=cfg.model,
context_window=get_context_window(cfg),
reserved_output_tokens=cfg.reserved_output_tokens,
default_template_overhead=cfg.template_overhead,
)

def count_diff_tokens(self, diffs: list[FileDiff]) -> int:
"""Calculate token count for a list of FileDiffs."""
if not diffs:
Expand Down
Loading
Loading