From 9cbf1ab367b15ad5b0f95654b315a1da7cd73552 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 25 Apr 2026 21:55:47 +0000 Subject: [PATCH] feat: bump to 0.3.0, single-source version, Voyage retry/backoff - pyproject.toml -> 0.3.0 (significant changes since 0.2.0: full Phase 1-7 KB stack, classifier+labels, batch-upload UI, pending-review panel, bucket rename to legal-kb) - api/__init__.py reads version from pyproject at import time so /api/health and OpenAPI both reflect reality without a second hardcoded copy - api/services/kb/voyage.py: _post_with_retry wraps embed/rerank POSTs with exponential backoff on 429+5xx (max 5 retries, base 2s, capped 30s, +25% jitter, honors Retry-After header). Dev and prod share a single VOYAGE_API_KEY today, so a simultaneous bulk re-ingest on both used to fail half the calls; now the burst is absorbed in seconds. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/__init__.py | 27 +++++++++++++++++++ api/main.py | 3 ++- api/routes/health.py | 6 ++--- api/services/kb/voyage.py | 57 +++++++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- 5 files changed, 88 insertions(+), 7 deletions(-) diff --git a/api/__init__.py b/api/__init__.py index e69de29..885e96e 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -0,0 +1,27 @@ +"""shira-hermes API package.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + + +def _read_version() -> str: + """Source-of-truth version from pyproject.toml. + + pyproject lives at the repo root; we walk up from this file + instead of relying on importlib.metadata so the version is + correct in editable installs and in the Docker image where the + package may not be `pip install`-ed at all. + """ + here = Path(__file__).resolve() + for parent in [here.parent, *here.parents]: + candidate = parent / "pyproject.toml" + if candidate.exists(): + with candidate.open("rb") as f: + data = tomllib.load(f) + return data.get("project", {}).get("version", "0.0.0") + return "0.0.0" + + +__version__ = _read_version() diff --git a/api/main.py b/api/main.py index d43ee0e..90380fa 100644 --- a/api/main.py +++ b/api/main.py @@ -9,6 +9,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from api import __version__ from api.routes.admin_kb import router as admin_kb_router from api.routes.health import router as health_router from api.routes.kb_public import router as kb_public_router @@ -24,7 +25,7 @@ logger = logging.getLogger("shira.app") app = FastAPI( title="shira-hermes", description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant", - version="0.2.0", + version=__version__, ) app.add_middleware( diff --git a/api/routes/health.py b/api/routes/health.py index 45139cf..d135570 100644 --- a/api/routes/health.py +++ b/api/routes/health.py @@ -6,9 +6,9 @@ from datetime import datetime, timezone from fastapi import APIRouter -router = APIRouter() +from api import __version__ -VERSION = "0.2.0" +router = APIRouter() @router.get("/api/health") @@ -16,6 +16,6 @@ async def health(): return { "status": "ok", "service": "shira-hermes", - "version": VERSION, + "version": __version__, "timestamp": datetime.now(timezone.utc).isoformat(), } diff --git a/api/services/kb/voyage.py b/api/services/kb/voyage.py index 0f4542c..97f2aa3 100644 --- a/api/services/kb/voyage.py +++ b/api/services/kb/voyage.py @@ -2,8 +2,10 @@ from __future__ import annotations +import asyncio import logging import os +import random from typing import Literal import httpx @@ -20,11 +22,58 @@ _MAX_BATCH_TOKENS = 90_000 # Voyage's multilingual tokenizer splits Hebrew more aggressively than ASCII. _TOKENS_PER_CHAR_EST = 0.55 +# Retry parameters for transient failures (429 + 5xx). Dev and prod share a +# single VOYAGE_API_KEY; without retry, simultaneous bulk re-ingests on both +# environments hit the per-key RPM limit and fail one of them. With +# exponential backoff (jittered) the burst is absorbed in seconds. +_MAX_RETRIES = 5 +_BASE_BACKOFF_S = 2.0 +_MAX_BACKOFF_S = 30.0 + class VoyageError(RuntimeError): pass +def _retry_after_seconds(resp: httpx.Response, attempt: int) -> float: + """Honor `Retry-After` header if present, else exponential backoff.""" + hdr = resp.headers.get("retry-after") + if hdr: + try: + return min(_MAX_BACKOFF_S, max(0.1, float(hdr))) + except ValueError: + pass + backoff = min(_MAX_BACKOFF_S, _BASE_BACKOFF_S * (2 ** attempt)) + return backoff + random.uniform(0, backoff * 0.25) + + +async def _post_with_retry( + http: httpx.AsyncClient, url: str, *, headers: dict, json: dict, what: str, +) -> httpx.Response: + """POST with exponential backoff on 429 and 5xx. + + Returns the final Response (caller still checks status_code for + non-retryable errors, e.g. 4xx other than 429). + """ + last_resp: httpx.Response | None = None + for attempt in range(_MAX_RETRIES + 1): + resp = await http.post(url, headers=headers, json=json) + last_resp = resp + if resp.status_code == 200: + return resp + if resp.status_code != 429 and resp.status_code < 500: + return resp + if attempt == _MAX_RETRIES: + return resp + delay = _retry_after_seconds(resp, attempt) + logger.warning( + "[kb.voyage] %s got %d, retry %d/%d in %.1fs", + what, resp.status_code, attempt + 1, _MAX_RETRIES, delay, + ) + await asyncio.sleep(delay) + return last_resp # type: ignore[return-value] + + async def embed( texts: list[str], input_type: Literal["document", "query"] = "document", @@ -56,10 +105,12 @@ async def embed( out: list[list[float]] = [] async with httpx.AsyncClient(timeout=120) as http: for bi, batch in enumerate(batches): - resp = await http.post( + resp = await _post_with_retry( + http, _VOYAGE_URL, headers={"Authorization": f"Bearer {api_key}"}, json={"model": model, "input": batch, "input_type": input_type}, + what=f"embed batch {bi+1}/{len(batches)}", ) if resp.status_code != 200: raise VoyageError(f"Voyage API {resp.status_code}: {resp.text[:200]}") @@ -104,10 +155,12 @@ async def rerank( payload["top_k"] = int(top_k) async with httpx.AsyncClient(timeout=60) as http: - resp = await http.post( + resp = await _post_with_retry( + http, _VOYAGE_RERANK_URL, headers={"Authorization": f"Bearer {api_key}"}, json=payload, + what="rerank", ) if resp.status_code != 200: raise VoyageError(f"Voyage rerank {resp.status_code}: {resp.text[:200]}") diff --git a/pyproject.toml b/pyproject.toml index 9898afa..0692290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shira-hermes" -version = "0.2.0" +version = "0.3.0" description = "Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant" requires-python = ">=3.11" dependencies = [