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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user