-
Notifications
You must be signed in to change notification settings - Fork 10
Health Probes: For llm_call endpoints #1021
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Prajna1999
wants to merge
21
commits into
main
Choose a base branch
from
feat/api-health-probes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
cb37d39
resolve merge conflict
Prajna1999 8e50f56
fix logger from warn to error
Prajna1999 d74e234
chore: remove error logging from creds error
Prajna1999 a077ef1
add health-probes to the list of cron endpoints
Prajna1999 cdc3ac6
feat: add more models and test cases
Prajna1999 652b048
Update backend/app/services/health_probes.py
Prajna1999 23ab51e
fix: remove extra google got 2.5-flash
Prajna1999 48c1c07
Merge remote-tracking branch 'refs/remotes/origin/feat/api-health-pro…
Prajna1999 6c0a146
test cases
Prajna1999 7c8db7e
Merge branch 'main' into feat/api-health-probes
Prajna1999 1ebef3d
resolved all comments and test cases
Prajna1999 b8c6aa0
redis hook up with round robin probe check
Prajna1999 cb9eb73
Update backend/app/api/routes/cron.py
Prajna1999 45d6b0d
Merge branch 'main' into feat/api-health-probes
Prajna1999 f857d57
Update backend/app/services/health_probes.py
Prajna1999 95a9f4c
Merge branch 'main' into feat/api-health-probes
Prajna1999 5836375
test with real payloads for all 13 probes
Prajna1999 34119e5
Merge branch 'main' into feat/api-health-probes
Prajna1999 52bdf6c
cleanup SRD and env.example
Prajna1999 bd8fef0
Merge remote-tracking branch 'refs/remotes/origin/feat/api-health-pro…
Prajna1999 8fa462b
Merge branch 'main' into feat/api-health-probes
Prajna1999 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Binary file not shown.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,268 @@ | ||
| import base64 | ||
| import logging | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path | ||
| from typing import Any, Literal | ||
| from uuid import UUID | ||
|
|
||
| import httpx | ||
| import redis | ||
| import sentry_sdk | ||
| from sentry_sdk.crons import MonitorStatus, capture_checkin | ||
| from sentry_sdk.types import MonitorConfig | ||
| from sqlmodel import Session | ||
|
|
||
| from app.core.config import settings | ||
| from app.core.db import engine | ||
| from app.models.job import Job, JobStatus | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| PROBE_INPUT = "ping" | ||
|
|
||
| # ~1sec "Hello" clip used as the STT probe's input audio. | ||
| STT_AUDIO_PATH = ( | ||
| Path(__file__).resolve().parents[1] / "assets" / "health_probe_hello.ogg" | ||
| ) | ||
| STT_AUDIO_MIME = "audio/ogg" | ||
| stt_audio_cache: dict[str, str | None] = {"value": None} | ||
|
|
||
| HEALTH_PROBES_MONITOR_SLUG = "health-probes-cron-job" | ||
| HEALTH_PROBES_MONITOR_CONFIG: MonitorConfig = { | ||
| "schedule": { | ||
| "type": "interval", | ||
| "value": settings.HEALTH_PROBE_INTERVAL_MINUTES, | ||
| "unit": "minute", | ||
| }, | ||
| "timezone": "UTC", | ||
| "checkin_margin": 2, | ||
| "max_runtime": 2 * settings.HEALTH_PROBE_INTERVAL_MINUTES, | ||
| "failure_issue_threshold": 2, | ||
| "recovery_threshold": 1, | ||
| } | ||
|
|
||
| # Rotation state lives in Redis | ||
| LAST_JOB_ID_KEY = "health_probe:last_job_id" | ||
| INDEX_KEY = "health_probe:index" | ||
|
|
||
| redis_client: redis.Redis = redis.from_url(settings.REDIS_URL, decode_responses=True) | ||
|
|
||
| Modality = Literal["text", "tts", "stt"] | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class Probe: | ||
| provider: str | ||
| model: str | ||
| modality: Modality | ||
| # Extra Kaapi params a provider's mapper requires beyond `model` | ||
| # (Sarvam TTS needs `language`, ElevenLabs TTS needs `voice`). | ||
| params: dict[str, Any] = field(default_factory=dict) | ||
|
|
||
|
|
||
| PROBES: list[Probe] = [ | ||
| # Text | ||
| Probe(provider="openai", model="gpt-4o-mini", modality="text"), | ||
| Probe(provider="google", model="gemini-2.5-flash", modality="text"), | ||
| Probe(provider="google", model="gemini-2.5-pro", modality="text"), | ||
| Probe(provider="anthropic", model="claude-sonnet-4-6", modality="text"), | ||
| # TTS | ||
| Probe(provider="google", model="gemini-2.5-flash-preview-tts", modality="tts"), | ||
| Probe(provider="google", model="gemini-3.1-flash-tts-preview", modality="tts"), | ||
| Probe(provider="google", model="gemini-2.5-pro-preview-tts", modality="tts"), | ||
| Probe( | ||
| provider="sarvamai", | ||
| model="bulbul:v3", | ||
| modality="tts", | ||
| params={"voice": "simran", "language": "en-IN"}, | ||
| ), | ||
| Probe( | ||
| provider="elevenlabs", | ||
| model="eleven_v3", | ||
| modality="tts", | ||
| params={"voice": "Sarah"}, | ||
| ), | ||
| # STT | ||
| Probe(provider="google", model="gemini-2.5-pro", modality="stt"), | ||
| Probe(provider="google", model="gemini-2.5-flash", modality="stt"), | ||
| Probe(provider="google", model="gemini-3.1-pro-preview", modality="stt"), | ||
| Probe(provider="sarvamai", model="saaras:v3", modality="stt"), | ||
| Probe(provider="elevenlabs", model="scribe_v2", modality="stt"), | ||
| ] | ||
|
|
||
|
|
||
| def load_stt_audio_b64() -> str: | ||
| if stt_audio_cache["value"] is None: | ||
| with open(STT_AUDIO_PATH, "rb") as f: | ||
| stt_audio_cache["value"] = base64.b64encode(f.read()).decode("ascii") | ||
| return stt_audio_cache["value"] | ||
|
|
||
|
|
||
| def build_probe_payload(probe: Probe, index: int) -> dict[str, Any]: | ||
| if probe.modality == "stt": | ||
| query_input: Any = { | ||
| "type": "audio", | ||
| "content": { | ||
| "format": "base64", | ||
| "value": load_stt_audio_b64(), | ||
| "mime_type": STT_AUDIO_MIME, | ||
| }, | ||
| } | ||
| else: | ||
| query_input = PROBE_INPUT | ||
|
|
||
| # No callback_url: the probe reads its result by polling `Job.status` on | ||
| # the next tick, so callback delivery (a best-effort side channel that | ||
| # never affects Job.status) buys nothing here. | ||
| return { | ||
| "query": {"input": query_input}, | ||
| "config": { | ||
| "blob": { | ||
| "completion": { | ||
| "provider": probe.provider, | ||
| "type": probe.modality, | ||
| "params": {"model": probe.model, **probe.params}, | ||
| } | ||
| } | ||
| }, | ||
| "include_provider_raw_response": False, | ||
| "request_metadata": { | ||
| "health_probe": True, | ||
| "probe_index": index, | ||
| "provider": probe.provider, | ||
| "modality": probe.modality, | ||
| "model": probe.model, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| def call_llm_probe(payload: dict[str, Any]) -> UUID: | ||
| if not settings.HEALTH_PROBE_API_KEY or not settings.HEALTH_PROBE_LLM_CALL_URL: | ||
| raise RuntimeError( | ||
| "[call_llm_probe] HEALTH_PROBE_API_KEY and HEALTH_PROBE_LLM_CALL_URL " | ||
| "must both be set to fire a health probe" | ||
| ) | ||
|
|
||
| headers = {"X-API-KEY": settings.HEALTH_PROBE_API_KEY} | ||
| response = httpx.post( | ||
| settings.HEALTH_PROBE_LLM_CALL_URL, | ||
| json=payload, | ||
| headers=headers, | ||
| timeout=30.0, | ||
| ) | ||
| response.raise_for_status() | ||
| body = response.json() | ||
| job_id_str = body["data"]["job_id"] | ||
| return UUID(job_id_str) | ||
|
|
||
|
|
||
| def check_previous_probe() -> JobStatus | None: | ||
| # Missing key (first run, Redis eviction) means skip the check-in, not fail the tick. | ||
| try: | ||
| last_job_id = redis_client.get(LAST_JOB_ID_KEY) | ||
| except redis.RedisError as e: | ||
| logger.warning(f"[check_previous_probe] Redis GET failed | error: {e}") | ||
| sentry_sdk.capture_exception(e, level="warning") | ||
| return None | ||
|
|
||
| if last_job_id is None: | ||
| logger.warning( | ||
| f"[check_previous_probe] {LAST_JOB_ID_KEY} missing — skipping check-in" | ||
| ) | ||
| sentry_sdk.capture_message( | ||
| f"[check_previous_probe] {LAST_JOB_ID_KEY} missing — skipping check-in", | ||
| level="warning", | ||
| ) | ||
| return None | ||
|
|
||
| try: | ||
| job_uuid = UUID(str(last_job_id)) | ||
| except ValueError as e: | ||
| logger.warning( | ||
| f"[check_previous_probe] Malformed job id | value: {last_job_id!r}, error: {e}" | ||
| ) | ||
| sentry_sdk.capture_exception(e, level="warning") | ||
| return None | ||
|
|
||
| with Session(engine) as session: | ||
| job = session.get(Job, job_uuid) | ||
|
|
||
| if job is None: | ||
| logger.warning(f"[check_previous_probe] Job not found | job_id: {last_job_id}") | ||
| sentry_sdk.capture_message( | ||
| f"[check_previous_probe] Job not found | job_id: {last_job_id}", | ||
| level="warning", | ||
| ) | ||
| return None | ||
|
|
||
| return job.status | ||
|
|
||
|
|
||
| def capture_previous_result_checkin(previous_status: JobStatus | None) -> None: | ||
| if previous_status is None: | ||
| return | ||
| # SUCCESS means the probe actually ran and returned; FAILED or a | ||
| # still-PENDING/PROCESSING job both mean a real failure. | ||
| monitor_status = ( | ||
| MonitorStatus.OK | ||
| if previous_status == JobStatus.SUCCESS | ||
| else MonitorStatus.ERROR | ||
| ) | ||
| capture_checkin( | ||
| monitor_slug=HEALTH_PROBES_MONITOR_SLUG, | ||
| status=monitor_status, | ||
| monitor_config=HEALTH_PROBES_MONITOR_CONFIG, | ||
| ) | ||
|
|
||
|
|
||
| def claim_next_probe_index() -> int: | ||
| # Redis INCR is atomic, so two overlapping ticks can never claim the same | ||
| # slot (which would fire the same probe twice while skipping another). | ||
| try: | ||
| count = redis_client.incr(INDEX_KEY) | ||
| except redis.RedisError as e: | ||
| logger.warning(f"[claim_next_probe_index] Redis INCR failed | error: {e}") | ||
| sentry_sdk.capture_exception(e, level="warning") | ||
| return 0 | ||
|
|
||
| try: | ||
| return (int(count) - 1) % len(PROBES) | ||
| except (TypeError, ValueError) as e: | ||
| logger.warning(f"[claim_next_probe_index] Non-integer index | raw: {count!r}") | ||
| sentry_sdk.capture_exception(e, level="warning") | ||
| return 0 | ||
|
|
||
|
|
||
| def store_last_job_id(job_id: UUID) -> None: | ||
| try: | ||
| redis_client.set(LAST_JOB_ID_KEY, str(job_id)) | ||
| except redis.RedisError as e: | ||
| logger.error( | ||
| f"[store_last_job_id] Redis SET failed | job_id: {job_id}, error: {e}" | ||
| ) | ||
| sentry_sdk.capture_exception(e) | ||
|
|
||
|
|
||
| def run_health_probe_tick() -> dict[str, Any]: | ||
| previous_status = check_previous_probe() | ||
| capture_previous_result_checkin(previous_status) | ||
|
|
||
| index = claim_next_probe_index() | ||
| probe = PROBES[index] | ||
| payload = build_probe_payload(probe, index) | ||
|
|
||
| job_id = call_llm_probe(payload) | ||
|
|
||
| store_last_job_id(job_id) | ||
|
|
||
| logger.info( | ||
| f"[run_health_probe_tick] Fired probe | provider: {probe.provider}, " | ||
| f"model: {probe.model}, modality: {probe.modality}, job_id: {job_id}, " | ||
| f"previous_job_status: {previous_status}" | ||
| ) | ||
| return { | ||
| "enqueued": True, | ||
| "job_id": job_id, | ||
| "probe_index": index, | ||
| "previous_job_status": previous_status.value if previous_status else None, | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use anthropic provider as well to test