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) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 21:55:47 +00:00
parent e4dab2507b
commit 9cbf1ab367
5 changed files with 88 additions and 7 deletions
+27
View File
@@ -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()
+2 -1
View File
@@ -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(
+3 -3
View File
@@ -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(),
}
+55 -2
View File
@@ -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]}")