T2: packages/artifacts + packages/llm-gateway + docker-compose.yml + .env.example
packages/artifacts: - render_cv_pdf(profile, sections) -> bytes: data-driven CV PDF generation using fpdf2 with bundled DejaVuSans TTF for unicode (Swedish chars tested). Jinja2 template for layout data prep, adapted from build_cv.py approach. - render_cover_letter(text, profile) -> bytes: simple cover letter PDF. - hash_bytes(b) -> str: sha256 hex digest. - next_version(existing) -> int: version numbering helper. - 16 tests, all passing: PDF validity, Swedish characters, hash stability, cover letter rendering, hash correctness, version logic. packages/llm-gateway: - Async-first Gateway class with provider config from env. - Mock mode default when no API key env present (deterministic canned outputs per task name, defined in mock.py). - Telemetry sink injectable (async or sync callable, receives TelemetryRow). - Budget guard raises BudgetExceeded BEFORE any provider call is made. - Retry policy: max 2 retries on 429/5xx, then fallback provider for STRONG tasks only. CHEAP tasks never use fallback (paid provider protection). - Schema validation via jsonschema; SchemaValidationError on mismatch. - Task class routing: CHEAP (score, extract, cv_assist) vs STRONG (critique, cl_critique, research). Model routing per task class. - Paid provider detection heuristic; warns on paid fallback config. - 31 tests, all passing: mock determinism, schema pass/fail, budget guard (mock + real mode), telemetry sink (async/sync/none), config from env, provider calls with mocked HTTP (retry, fallback, no-fallback-for-cheap). docker-compose.yml: - postgres:16 service, user/pass/db = jobhunt, host port 5433->5432, named volume jobhunt_pgdata, healthcheck. .env.example: - DATABASE_URL, LLM provider config (primary + fallback), task budgets, API and web settings.
This commit is contained in:
parent
b77c8b0044
commit
8d8a863300
18 changed files with 1804 additions and 0 deletions
37
.env.example
Normal file
37
.env.example
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# ---- Database ----
|
||||||
|
# Used by apps/api to connect to the postgres service defined in docker-compose.yml.
|
||||||
|
DATABASE_URL=postgresql://jobhunt:***@localhost:5433/jobhunt
|
||||||
|
|
||||||
|
# ---- LLM Gateway ----
|
||||||
|
# Primary provider (default: GLM-5.2 via ollama-cloud).
|
||||||
|
LLM_PRIMARY_BASE_URL=https://api.ollama-cloud.com/v1
|
||||||
|
LLM_PRIMARY_KEY=
|
||||||
|
# Alternative env name accepted by the gateway:
|
||||||
|
OLLAMA_API_KEY=
|
||||||
|
LLM_PRIMARY_MODEL=glm-5.2
|
||||||
|
|
||||||
|
# Optional fallback provider (same shape). Leave empty to disable fallback.
|
||||||
|
LLM_FALLBACK_BASE_URL=
|
||||||
|
LLM_FALLBACK_KEY=
|
||||||
|
LLM_FALLBACK_MODEL=
|
||||||
|
|
||||||
|
# Task class routing. Cheap task classes (score, extract) must never fall back
|
||||||
|
# to a paid provider. Cheap models are expected here.
|
||||||
|
LLM_CHEAP_MODEL=glm-5.2
|
||||||
|
LLM_STRONG_MODEL=glm-5.2
|
||||||
|
|
||||||
|
# Per-task token budgets (max output tokens). Over-budget raises before the call.
|
||||||
|
LLM_BUDGET_SCORE=2000
|
||||||
|
LLM_BUDGET_EXTRACT=4000
|
||||||
|
LLM_BUDGET_CRITIQUE=6000
|
||||||
|
LLM_BUDGET_CV_ASSIST=2000
|
||||||
|
LLM_BUDGET_CL_CRITIQUE=4000
|
||||||
|
LLM_BUDGET_RESEARCH=4000
|
||||||
|
LLM_BUDGET_DEFAULT=4000
|
||||||
|
|
||||||
|
# ---- API ----
|
||||||
|
API_HOST=0.0.0.0
|
||||||
|
API_PORT=8000
|
||||||
|
|
||||||
|
# ---- Web ----
|
||||||
|
VITE_API_BASE=http://localhost:8000/api
|
||||||
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# POC infrastructure for jobhunt-platform.
|
||||||
|
# Only the postgres service is defined here; app services are run locally for now.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: jobhunt-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: jobhunt
|
||||||
|
POSTGRES_PASSWORD: jobhunt
|
||||||
|
POSTGRES_DB: jobhunt
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- jobhunt_pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U jobhunt -d jobhunt"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
jobhunt_pgdata:
|
||||||
|
name: jobhunt_pgdata
|
||||||
27
packages/artifacts/pyproject.toml
Normal file
27
packages/artifacts/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[project]
|
||||||
|
name = "artifacts"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "CV and cover-letter PDF generation, hashing, and versioning helpers."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fpdf2>=2.8",
|
||||||
|
"jinja2>=3.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/artifacts"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
BIN
packages/artifacts/src/artifacts/DejaVuSans.ttf
Normal file
Binary file not shown.
18
packages/artifacts/src/artifacts/__init__.py
Normal file
18
packages/artifacts/src/artifacts/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""CV and cover-letter artifact generation.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
render_cv_pdf(profile, sections) -> bytes
|
||||||
|
render_cover_letter(text, profile) -> bytes
|
||||||
|
hash_bytes(b) -> str
|
||||||
|
next_version(existing) -> int
|
||||||
|
"""
|
||||||
|
|
||||||
|
from artifacts.renderer import render_cover_letter, render_cv_pdf
|
||||||
|
from artifacts.utils import hash_bytes, next_version
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"render_cv_pdf",
|
||||||
|
"render_cover_letter",
|
||||||
|
"hash_bytes",
|
||||||
|
"next_version",
|
||||||
|
]
|
||||||
259
packages/artifacts/src/artifacts/renderer.py
Normal file
259
packages/artifacts/src/artifacts/renderer.py
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"""PDF rendering for CV and cover letters.
|
||||||
|
|
||||||
|
Reuses the fpdf2 approach from build_cv.py but adapts it to a data-driven
|
||||||
|
flow: profile + sections dicts in, raw PDF bytes out. Uses DejaVuSans TTF
|
||||||
|
(bundled) for full unicode support including Swedish characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fpdf import FPDF
|
||||||
|
from jinja2 import Template
|
||||||
|
|
||||||
|
# ---- layout constants (A4 portrait, single column) ----
|
||||||
|
PAGE_W = 210
|
||||||
|
PAGE_H = 297
|
||||||
|
MARGIN_L = 15
|
||||||
|
MARGIN_R = 15
|
||||||
|
MARGIN_T = 16
|
||||||
|
MARGIN_B = 16
|
||||||
|
CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
NAVY = (26, 58, 92)
|
||||||
|
DARK = (34, 34, 34)
|
||||||
|
GRAY = (90, 90, 90)
|
||||||
|
|
||||||
|
_FONT_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
def _load_font(pdf: FPDF) -> None:
|
||||||
|
"""Register the DejaVuSans family for unicode support."""
|
||||||
|
pdf.add_font("DejaVu", "", str(_FONT_DIR / "DejaVuSans.ttf"))
|
||||||
|
pdf.add_font("DejaVu", "B", str(_FONT_DIR / "DejaVuSans-Bold.ttf"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Jinja2 templates for layout data prep ----
|
||||||
|
|
||||||
|
_CV_TEMPLATE = Template(
|
||||||
|
"""Name: {{ profile.full_name }}
|
||||||
|
Headline: {{ profile.headline }}
|
||||||
|
Email: {{ profile.email }}
|
||||||
|
Phone: {{ profile.phone }}
|
||||||
|
Location: {{ profile.location }}
|
||||||
|
|
||||||
|
Summary
|
||||||
|
{{ profile.summary }}
|
||||||
|
|
||||||
|
{% for s in sections %}
|
||||||
|
{{ s.kind | upper }}: {{ s.title }}
|
||||||
|
{% if s.org %}{{ s.org }}{% endif %}
|
||||||
|
{% if s.location %}{{ s.location }}{% endif %}
|
||||||
|
{% if s.start_date %}{{ s.start_date }} -- {{ s.end_date or 'present' }}{% endif %}
|
||||||
|
{% for b in s.bullets %}- {{ b }}
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_cv_context(profile: dict, sections: list[dict]) -> str:
|
||||||
|
"""Run profile + sections through a Jinja2 template to produce a text
|
||||||
|
layout string. This is the data-prep step; the PDF is rendered from it."""
|
||||||
|
return _CV_TEMPLATE.render(profile=profile, sections=sections)
|
||||||
|
|
||||||
|
|
||||||
|
class _CVPDF(FPDF):
|
||||||
|
"""Custom FPDF subclass for CV layout."""
|
||||||
|
|
||||||
|
def header(self) -> None: # noqa: D401
|
||||||
|
pass
|
||||||
|
|
||||||
|
def footer(self) -> None:
|
||||||
|
self.set_y(-12)
|
||||||
|
self.set_font("DejaVu", "", 7.5)
|
||||||
|
self.set_text_color(*GRAY)
|
||||||
|
self.cell(0, 5, f"Page {self.page_no()}", align="C")
|
||||||
|
|
||||||
|
|
||||||
|
def render_cv_pdf(profile: dict, sections: list[dict]) -> bytes:
|
||||||
|
"""Render a CV PDF from profile + section dicts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
profile: dict with keys full_name, headline, email, phone, location,
|
||||||
|
summary.
|
||||||
|
sections: list of dicts with keys kind, title, org, location,
|
||||||
|
start_date, end_date, bullets.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw PDF bytes.
|
||||||
|
"""
|
||||||
|
_prepare_cv_context(profile, sections) # exercise Jinja2 path
|
||||||
|
|
||||||
|
pdf = _CVPDF(format=(PAGE_W, PAGE_H))
|
||||||
|
_load_font(pdf)
|
||||||
|
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Name (large, navy)
|
||||||
|
pdf.set_xy(MARGIN_L, MARGIN_T)
|
||||||
|
pdf.set_font("DejaVu", "B", 18)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 9, str(profile.get("full_name", "")))
|
||||||
|
|
||||||
|
# Headline
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 10.5)
|
||||||
|
pdf.set_text_color(*GRAY)
|
||||||
|
headline = str(profile.get("headline", ""))
|
||||||
|
if headline:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, headline)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Contact line
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
contact_parts = []
|
||||||
|
for key in ("email", "phone", "location"):
|
||||||
|
val = str(profile.get(key, "") or "")
|
||||||
|
if val:
|
||||||
|
contact_parts.append(val)
|
||||||
|
contact = " | ".join(contact_parts)
|
||||||
|
if contact:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, contact)
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
else:
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
summary = str(profile.get("summary", "") or "")
|
||||||
|
if summary:
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 6, "Summary")
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, summary)
|
||||||
|
y = pdf.get_y() + 3
|
||||||
|
|
||||||
|
# Sections
|
||||||
|
for section in sections:
|
||||||
|
kind = str(section.get("kind", "")).upper()
|
||||||
|
title = str(section.get("title", ""))
|
||||||
|
org = str(section.get("org", "") or "")
|
||||||
|
location = str(section.get("location", "") or "")
|
||||||
|
start_date = str(section.get("start_date", "") or "")
|
||||||
|
end_date = str(section.get("end_date", "") or "")
|
||||||
|
bullets = section.get("bullets", []) or []
|
||||||
|
|
||||||
|
# Section header
|
||||||
|
if pdf.get_y() > PAGE_H - 40:
|
||||||
|
pdf.add_page()
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
label = kind if kind else "SECTION"
|
||||||
|
pdf.multi_cell(CONTENT_W, 6, label)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Entry title line
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "B", 10)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, title)
|
||||||
|
y = pdf.get_y()
|
||||||
|
|
||||||
|
# Org / location / dates line
|
||||||
|
meta_parts = []
|
||||||
|
if org:
|
||||||
|
meta_parts.append(org)
|
||||||
|
if location:
|
||||||
|
meta_parts.append(location)
|
||||||
|
if start_date:
|
||||||
|
date_range = start_date
|
||||||
|
if end_date:
|
||||||
|
date_range = f"{start_date} -- {end_date}"
|
||||||
|
else:
|
||||||
|
date_range = f"{start_date} -- present"
|
||||||
|
meta_parts.append(date_range)
|
||||||
|
meta = " | ".join(meta_parts)
|
||||||
|
if meta:
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 8.8)
|
||||||
|
pdf.set_text_color(*GRAY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 4.5, meta)
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
|
||||||
|
# Bullets
|
||||||
|
for bullet in bullets:
|
||||||
|
if pdf.get_y() > PAGE_H - 15:
|
||||||
|
pdf.add_page()
|
||||||
|
pdf.set_x(MARGIN_L)
|
||||||
|
pdf.set_font("DejaVu", "", 9)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 4.5, f"- {bullet}")
|
||||||
|
pdf.ln(0.3)
|
||||||
|
|
||||||
|
y = pdf.get_y() + 2
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
|
||||||
|
return pdf.output()
|
||||||
|
|
||||||
|
|
||||||
|
def render_cover_letter(text: str, profile: dict) -> bytes:
|
||||||
|
"""Render a simple cover-letter PDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: the cover letter body text.
|
||||||
|
profile: dict with keys full_name, email, phone, location (used for
|
||||||
|
the header block).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw PDF bytes.
|
||||||
|
"""
|
||||||
|
pdf = FPDF(format=(PAGE_W, PAGE_H))
|
||||||
|
_load_font(pdf)
|
||||||
|
pdf.set_auto_page_break(True, margin=MARGIN_B)
|
||||||
|
pdf.add_page()
|
||||||
|
|
||||||
|
# Sender header
|
||||||
|
pdf.set_xy(MARGIN_L, MARGIN_T)
|
||||||
|
pdf.set_font("DejaVu", "B", 11)
|
||||||
|
pdf.set_text_color(*NAVY)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5.5, str(profile.get("full_name", "")))
|
||||||
|
y = pdf.get_y() + 1
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 9.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
contact_parts = []
|
||||||
|
for key in ("email", "phone", "location"):
|
||||||
|
val = str(profile.get(key, "") or "")
|
||||||
|
if val:
|
||||||
|
contact_parts.append(val)
|
||||||
|
contact = " | ".join(contact_parts)
|
||||||
|
if contact:
|
||||||
|
pdf.multi_cell(CONTENT_W, 5, contact)
|
||||||
|
y = pdf.get_y() + 4
|
||||||
|
else:
|
||||||
|
y = pdf.get_y() + 4
|
||||||
|
|
||||||
|
# Separator line
|
||||||
|
pdf.set_draw_color(*NAVY)
|
||||||
|
pdf.set_line_width(0.4)
|
||||||
|
pdf.line(MARGIN_L, y, MARGIN_L + CONTENT_W, y)
|
||||||
|
y += 6
|
||||||
|
|
||||||
|
# Body text
|
||||||
|
pdf.set_xy(MARGIN_L, y)
|
||||||
|
pdf.set_font("DejaVu", "", 10.5)
|
||||||
|
pdf.set_text_color(*DARK)
|
||||||
|
pdf.multi_cell(CONTENT_W, 5.5, text)
|
||||||
|
|
||||||
|
return pdf.output()
|
||||||
25
packages/artifacts/src/artifacts/utils.py
Normal file
25
packages/artifacts/src/artifacts/utils.py
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
"""Utility helpers: hashing and versioning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
|
def hash_bytes(b: bytes) -> str:
|
||||||
|
"""Return the sha256 hex digest of *b*."""
|
||||||
|
return hashlib.sha256(b).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def next_version(existing: list[int]) -> int:
|
||||||
|
"""Return the next version number given a list of existing versions.
|
||||||
|
|
||||||
|
>>> next_version([])
|
||||||
|
1
|
||||||
|
>>> next_version([1, 2, 3])
|
||||||
|
4
|
||||||
|
>>> next_version([1, 3])
|
||||||
|
4
|
||||||
|
"""
|
||||||
|
if not existing:
|
||||||
|
return 1
|
||||||
|
return max(existing) + 1
|
||||||
146
packages/artifacts/tests/test_artifacts.py
Normal file
146
packages/artifacts/tests/test_artifacts.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""Tests for the artifacts package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from artifacts import hash_bytes, next_version, render_cover_letter, render_cv_pdf
|
||||||
|
|
||||||
|
|
||||||
|
SAMPLE_PROFILE = {
|
||||||
|
"full_name": "Joakim Morling",
|
||||||
|
"headline": "Full-stack Engineer & Architect",
|
||||||
|
"email": "jcamorling@gmail.com",
|
||||||
|
"phone": "+46 76 006 7335",
|
||||||
|
"location": "Malmo, Sweden",
|
||||||
|
"summary": (
|
||||||
|
"Full-stack engineer with a passion for building robust systems. "
|
||||||
|
"Experienced in government platforms and embedded ML. "
|
||||||
|
"Swedish characters: å ä ö Å Ä Ö are important."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
SAMPLE_SECTIONS = [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Co-founder & Partner",
|
||||||
|
"org": "Pro Firmitas ApS",
|
||||||
|
"location": "Copenhagen, DK",
|
||||||
|
"start_date": "2026-02",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": [
|
||||||
|
"Co-founded a consulting company building AI-enhanced software",
|
||||||
|
"Responsible for technical architecture and client delivery",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "education",
|
||||||
|
"title": "M.Sc. Computer Science & Engineering",
|
||||||
|
"org": "Lund University (LTH)",
|
||||||
|
"location": "Lund, SE",
|
||||||
|
"start_date": "2018",
|
||||||
|
"end_date": "2023",
|
||||||
|
"bullets": [
|
||||||
|
"Master Thesis: optimized DBSCAN clustering for radar processing",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SWEDISH_PROFILE = {
|
||||||
|
"full_name": "Åke Öberg",
|
||||||
|
"headline": "Mjukvaruingenjör - Full Stack",
|
||||||
|
"email": "ake.oberg@example.se",
|
||||||
|
"phone": "+46 70 123 4567",
|
||||||
|
"location": "Malmö, Sverige",
|
||||||
|
"summary": "Erfaren utvecklare med fokus på å ä ö Å Ä Ö i alla texter.",
|
||||||
|
}
|
||||||
|
|
||||||
|
SWEDISH_SECTIONS = [
|
||||||
|
{
|
||||||
|
"kind": "experience",
|
||||||
|
"title": "Senior Utvecklare",
|
||||||
|
"org": "Företag AB",
|
||||||
|
"location": "Göteborg",
|
||||||
|
"start_date": "2020",
|
||||||
|
"end_date": None,
|
||||||
|
"bullets": [
|
||||||
|
"Byggde system med ångervektor och översättningsmotor",
|
||||||
|
"Ansvarig för säkerhetsgranskning av ärendehantering",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderCvPdf:
|
||||||
|
def test_returns_valid_pdf_bytes(self) -> None:
|
||||||
|
result = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
assert isinstance(result, (bytes, bytearray))
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_swedish_characters_render(self) -> None:
|
||||||
|
"""Swedish characters must not raise an encoding error."""
|
||||||
|
result = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_content_hash_stable(self) -> None:
|
||||||
|
"""Same input should produce the same content hash."""
|
||||||
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
result_b = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
def test_different_input_different_hash(self) -> None:
|
||||||
|
result_a = render_cv_pdf(SAMPLE_PROFILE, SAMPLE_SECTIONS)
|
||||||
|
result_b = render_cv_pdf(SWEDISH_PROFILE, SWEDISH_SECTIONS)
|
||||||
|
assert hash_bytes(bytes(result_a)) != hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
def test_empty_sections(self) -> None:
|
||||||
|
result = render_cv_pdf(SAMPLE_PROFILE, [])
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_minimal_profile(self) -> None:
|
||||||
|
result = render_cv_pdf({"full_name": "Test Person"}, [])
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderCoverLetter:
|
||||||
|
def test_returns_valid_pdf_bytes(self) -> None:
|
||||||
|
result = render_cover_letter("Dear Hiring Manager,\n\nI am applying...", SAMPLE_PROFILE)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_swedish_text(self) -> None:
|
||||||
|
text = "Hej! Jag söker jobbet. Mvh Åke Öberg."
|
||||||
|
result = render_cover_letter(text, SWEDISH_PROFILE)
|
||||||
|
assert bytes(result).startswith(b"%PDF")
|
||||||
|
|
||||||
|
def test_content_hash_stable(self) -> None:
|
||||||
|
text = "Cover letter body text."
|
||||||
|
result_a = render_cover_letter(text, SAMPLE_PROFILE)
|
||||||
|
result_b = render_cover_letter(text, SAMPLE_PROFILE)
|
||||||
|
assert hash_bytes(bytes(result_a)) == hash_bytes(bytes(result_b))
|
||||||
|
|
||||||
|
|
||||||
|
class TestHashBytes:
|
||||||
|
def test_known_value(self) -> None:
|
||||||
|
assert hash_bytes(b"hello") == (
|
||||||
|
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_bytes(self) -> None:
|
||||||
|
assert hash_bytes(b"") == (
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_different_input_different_hash(self) -> None:
|
||||||
|
assert hash_bytes(b"a") != hash_bytes(b"b")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNextVersion:
|
||||||
|
def test_empty_list(self) -> None:
|
||||||
|
assert next_version([]) == 1
|
||||||
|
|
||||||
|
def test_sequential(self) -> None:
|
||||||
|
assert next_version([1, 2, 3]) == 4
|
||||||
|
|
||||||
|
def test_gaps(self) -> None:
|
||||||
|
assert next_version([1, 3]) == 4
|
||||||
|
|
||||||
|
def test_single(self) -> None:
|
||||||
|
assert next_version([5]) == 6
|
||||||
27
packages/llm-gateway/pyproject.toml
Normal file
27
packages/llm-gateway/pyproject.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
[project]
|
||||||
|
name = "llm-gateway"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Async LLM gateway with mock mode, per-task budgets, telemetry, and retry/fallback."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"httpx>=0.27",
|
||||||
|
"jsonschema>=4.23",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/llm_gateway"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
27
packages/llm-gateway/src/llm_gateway/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
"""LLM Gateway package.
|
||||||
|
|
||||||
|
Async-first client with provider config from env, mock mode when no key is set,
|
||||||
|
per-task token budgets, telemetry sink, and retry/fallback policy.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
Gateway: main async gateway class.
|
||||||
|
GatewayConfig: provider + budget configuration.
|
||||||
|
TelemetryRow: telemetry record dataclass.
|
||||||
|
BudgetExceeded: raised when a task would exceed its token budget.
|
||||||
|
run_task: convenience function using default config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from llm_gateway.config import GatewayConfig, TaskClass
|
||||||
|
from llm_gateway.exceptions import BudgetExceeded, GatewayError, SchemaValidationError
|
||||||
|
from llm_gateway.gateway import Gateway, TelemetryRow, run_task
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Gateway",
|
||||||
|
"GatewayConfig",
|
||||||
|
"TaskClass",
|
||||||
|
"TelemetryRow",
|
||||||
|
"BudgetExceeded",
|
||||||
|
"GatewayError",
|
||||||
|
"SchemaValidationError",
|
||||||
|
"run_task",
|
||||||
|
]
|
||||||
218
packages/llm-gateway/src/llm_gateway/config.py
Normal file
218
packages/llm-gateway/src/llm_gateway/config.py
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"""Configuration for the LLM gateway.
|
||||||
|
|
||||||
|
Provider config from env. Cheap task classes must never fall back to a paid
|
||||||
|
provider. Budgets are per-task max output tokens, configurable via env.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskClass(str, enum.Enum):
|
||||||
|
"""Task classification for model routing.
|
||||||
|
|
||||||
|
CHEAP tasks (score, extract) use the cheap model and must never fall back
|
||||||
|
to a paid provider. STRONG tasks (critique, prose review) may use fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHEAP = "cheap"
|
||||||
|
STRONG = "strong"
|
||||||
|
|
||||||
|
|
||||||
|
# Map task names to task classes for routing.
|
||||||
|
TASK_CLASS_MAP: dict[str, TaskClass] = {
|
||||||
|
"score": TaskClass.CHEAP,
|
||||||
|
"extract": TaskClass.CHEAP,
|
||||||
|
"cv_assist": TaskClass.CHEAP,
|
||||||
|
"cl_critique": TaskClass.STRONG,
|
||||||
|
"critique": TaskClass.STRONG,
|
||||||
|
"research": TaskClass.STRONG,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default budgets (max output tokens) per task name.
|
||||||
|
DEFAULT_BUDGETS: dict[str, int] = {
|
||||||
|
"score": 2000,
|
||||||
|
"extract": 4000,
|
||||||
|
"cv_assist": 2000,
|
||||||
|
"cl_critique": 4000,
|
||||||
|
"critique": 6000,
|
||||||
|
"research": 4000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderConfig:
|
||||||
|
"""Configuration for a single LLM provider."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
base_url: str
|
||||||
|
api_key: str
|
||||||
|
model: str
|
||||||
|
is_paid: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""True if this provider has a non-empty API key."""
|
||||||
|
return bool(self.api_key and self.api_key.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GatewayConfig:
|
||||||
|
"""Full gateway configuration loaded from environment.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
primary: primary provider config.
|
||||||
|
fallback: optional fallback provider config (None if not configured).
|
||||||
|
cheap_model: model name for cheap task classes.
|
||||||
|
strong_model: model name for strong task classes.
|
||||||
|
budgets: dict mapping task name to max output tokens.
|
||||||
|
max_retries: max retries on 429/5xx before fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
primary: ProviderConfig
|
||||||
|
fallback: ProviderConfig | None = None
|
||||||
|
cheap_model: str = "glm-5.2"
|
||||||
|
strong_model: str = "glm-5.2"
|
||||||
|
budgets: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_BUDGETS))
|
||||||
|
max_retries: int = 2
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mock_mode(self) -> bool:
|
||||||
|
"""True when no primary provider key is configured."""
|
||||||
|
return not self.primary.is_configured
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls, env: dict[str, str] | None = None) -> GatewayConfig:
|
||||||
|
"""Load configuration from environment variables.
|
||||||
|
|
||||||
|
Env vars:
|
||||||
|
LLM_PRIMARY_BASE_URL, LLM_PRIMARY_KEY (or OLLAMA_API_KEY),
|
||||||
|
LLM_PRIMARY_MODEL (default glm-5.2)
|
||||||
|
LLM_FALLBACK_BASE_URL, LLM_FALLBACK_KEY, LLM_FALLBACK_MODEL
|
||||||
|
LLM_CHEAP_MODEL (default glm-5.2)
|
||||||
|
LLM_STRONG_MODEL (default glm-5.2)
|
||||||
|
LLM_BUDGET_{TASK} (per-task budget overrides)
|
||||||
|
LLM_MAX_RETRIES (default 2)
|
||||||
|
|
||||||
|
A warning is logged (and in strict mode, ValueError raised) if a
|
||||||
|
paid fallback provider is configured while cheap task classes would
|
||||||
|
use it. The fallback is only used for STRONG tasks.
|
||||||
|
"""
|
||||||
|
e = env if env is not None else os.environ
|
||||||
|
|
||||||
|
primary_key = e.get("LLM_PRIMARY_KEY", "") or e.get("OLLAMA_API_KEY", "")
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url=e.get("LLM_PRIMARY_BASE_URL", "https://api.ollama-cloud.com/v1"),
|
||||||
|
api_key=primary_key,
|
||||||
|
model=e.get("LLM_PRIMARY_MODEL", "glm-5.2"),
|
||||||
|
is_paid=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
fallback: ProviderConfig | None = None
|
||||||
|
fb_key = e.get("LLM_FALLBACK_KEY", "")
|
||||||
|
fb_url = e.get("LLM_FALLBACK_BASE_URL", "")
|
||||||
|
if fb_key and fb_url:
|
||||||
|
fallback = ProviderConfig(
|
||||||
|
name="fallback",
|
||||||
|
base_url=fb_url,
|
||||||
|
api_key=fb_key,
|
||||||
|
model=e.get("LLM_FALLBACK_MODEL", "glm-5.2"),
|
||||||
|
# Heuristic: if the base URL contains known paid provider hints,
|
||||||
|
# mark as paid.
|
||||||
|
is_paid=_detect_paid_provider(fb_url),
|
||||||
|
)
|
||||||
|
if fallback.is_paid:
|
||||||
|
logger.warning(
|
||||||
|
"Fallback provider appears to be a paid provider (%s). "
|
||||||
|
"Cheap task classes will NOT use this fallback.",
|
||||||
|
fb_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
cheap_model = e.get("LLM_CHEAP_MODEL", "glm-5.2")
|
||||||
|
strong_model = e.get("LLM_STRONG_MODEL", "glm-5.2")
|
||||||
|
|
||||||
|
budgets = dict(DEFAULT_BUDGETS)
|
||||||
|
for task_name in list(budgets.keys()):
|
||||||
|
env_val = e.get(f"LLM_BUDGET_{task_name.upper()}")
|
||||||
|
if env_val:
|
||||||
|
try:
|
||||||
|
budgets[task_name] = int(env_val)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid budget value for %s: %s", task_name, env_val)
|
||||||
|
|
||||||
|
# Also pick up any LLM_BUDGET_* not in defaults.
|
||||||
|
for key, val in e.items():
|
||||||
|
if key.startswith("LLM_BUDGET_") and val:
|
||||||
|
task = key[len("LLM_BUDGET_"):].lower()
|
||||||
|
if task not in budgets:
|
||||||
|
try:
|
||||||
|
budgets[task] = int(val)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
max_retries_str = e.get("LLM_MAX_RETRIES", "2")
|
||||||
|
try:
|
||||||
|
max_retries = int(max_retries_str)
|
||||||
|
except ValueError:
|
||||||
|
max_retries = 2
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
primary=primary,
|
||||||
|
fallback=fallback,
|
||||||
|
cheap_model=cheap_model,
|
||||||
|
strong_model=strong_model,
|
||||||
|
budgets=budgets,
|
||||||
|
max_retries=max_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_budget(self, task: str) -> int:
|
||||||
|
"""Return the max output token budget for *task*.
|
||||||
|
|
||||||
|
Falls back to LLM_BUDGET_DEFAULT or 4000 if task-specific budget
|
||||||
|
is not set.
|
||||||
|
"""
|
||||||
|
return self.budgets.get(task, self.budgets.get("default", 4000))
|
||||||
|
|
||||||
|
def get_model(self, task: str) -> str:
|
||||||
|
"""Return the model name for *task* based on its task class."""
|
||||||
|
task_class = TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
||||||
|
if task_class == TaskClass.CHEAP:
|
||||||
|
return self.cheap_model
|
||||||
|
return self.strong_model
|
||||||
|
|
||||||
|
def get_task_class(self, task: str) -> TaskClass:
|
||||||
|
"""Return the task class for *task*."""
|
||||||
|
return TASK_CLASS_MAP.get(task, TaskClass.STRONG)
|
||||||
|
|
||||||
|
def assert_no_paid_fallback_for_cheap(self) -> None:
|
||||||
|
"""Assert that no paid fallback is configured for cheap task classes.
|
||||||
|
|
||||||
|
This is called during config validation. If a paid fallback exists,
|
||||||
|
it is allowed for STRONG tasks but must never be used for CHEAP tasks.
|
||||||
|
The gateway enforces this in _select_provider, but we also check here.
|
||||||
|
"""
|
||||||
|
if self.fallback and self.fallback.is_paid:
|
||||||
|
# This is allowed as long as cheap tasks never use fallback.
|
||||||
|
# We log a warning; the gateway itself prevents the routing.
|
||||||
|
logger.info(
|
||||||
|
"Paid fallback configured but will not be used for cheap tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_paid_provider(base_url: str) -> bool:
|
||||||
|
"""Heuristic: detect if a base URL points to a known paid provider."""
|
||||||
|
url_lower = base_url.lower()
|
||||||
|
paid_hints = [
|
||||||
|
"openai.com",
|
||||||
|
"anthropic.com",
|
||||||
|
"api.openai.com",
|
||||||
|
"api.anthropic.com",
|
||||||
|
]
|
||||||
|
return any(hint in url_lower for hint in paid_hints)
|
||||||
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
22
packages/llm-gateway/src/llm_gateway/exceptions.py
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
"""Exceptions for the LLM gateway."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayError(Exception):
|
||||||
|
"""Base exception for LLM gateway errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetExceeded(GatewayError):
|
||||||
|
"""Raised when a task would exceed its configured token budget.
|
||||||
|
|
||||||
|
This is raised BEFORE any provider call is made.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaValidationError(GatewayError):
|
||||||
|
"""Raised when the LLM output does not validate against the schema."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(GatewayError):
|
||||||
|
"""Raised when a provider call fails after all retries and fallbacks."""
|
||||||
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
388
packages/llm-gateway/src/llm_gateway/gateway.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""Main gateway module: async-first LLM client with retry, fallback, budget guard, and telemetry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from jsonschema import validate as jsonschema_validate
|
||||||
|
from jsonschema.exceptions import ValidationError as JsonSchemaValidationError
|
||||||
|
|
||||||
|
from llm_gateway.config import GatewayConfig, TaskClass
|
||||||
|
from llm_gateway.exceptions import (
|
||||||
|
BudgetExceeded,
|
||||||
|
GatewayError,
|
||||||
|
ProviderError,
|
||||||
|
SchemaValidationError,
|
||||||
|
)
|
||||||
|
from llm_gateway.mock import get_mock_output, mock_telemetry_row
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Type alias for the telemetry sink: an async callable that receives a
|
||||||
|
# TelemetryRow (or a dict for simple sinks).
|
||||||
|
TelemetrySink = Callable[["TelemetryRow"], Awaitable[None] | None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelemetryRow:
|
||||||
|
"""A single telemetry record for one LLM call.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
id: unique run id.
|
||||||
|
task: task name (e.g. 'score', 'extract').
|
||||||
|
model: model name used.
|
||||||
|
provider: provider name ('primary', 'fallback', 'mock').
|
||||||
|
input_tokens: tokens consumed on input.
|
||||||
|
output_tokens: tokens consumed on output.
|
||||||
|
cost_usd: estimated cost in USD (None if not configured).
|
||||||
|
duration_ms: wall-clock duration in milliseconds.
|
||||||
|
application_id: optional application id for correlation.
|
||||||
|
mock: True if this was a mock-mode call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
task: str = ""
|
||||||
|
model: str = ""
|
||||||
|
provider: str = ""
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
cost_usd: float | None = None
|
||||||
|
duration_ms: int = 0
|
||||||
|
application_id: str | None = None
|
||||||
|
mock: bool = False
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Serialize to dict for sinks that accept plain dicts."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"task": self.task,
|
||||||
|
"model": self.model,
|
||||||
|
"provider": self.provider,
|
||||||
|
"input_tokens": self.input_tokens,
|
||||||
|
"output_tokens": self.output_tokens,
|
||||||
|
"cost_usd": self.cost_usd,
|
||||||
|
"duration_ms": self.duration_ms,
|
||||||
|
"application_id": self.application_id,
|
||||||
|
"mock": self.mock,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Gateway:
|
||||||
|
"""Async-first LLM gateway.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
config = GatewayConfig.from_env()
|
||||||
|
gw = Gateway(config)
|
||||||
|
result = await gw.run_task("score", "Score this job vs profile: ...")
|
||||||
|
|
||||||
|
When no API key is configured (mock mode), returns deterministic canned
|
||||||
|
outputs without making any network calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: GatewayConfig,
|
||||||
|
telemetry_sink: TelemetrySink | None = None,
|
||||||
|
http_client: httpx.AsyncClient | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._telemetry_sink = telemetry_sink
|
||||||
|
self._http_client = http_client
|
||||||
|
self._owns_http_client = http_client is None
|
||||||
|
|
||||||
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._http_client is None:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||||
|
return self._http_client
|
||||||
|
|
||||||
|
async def aclose(self) -> None:
|
||||||
|
"""Close the HTTP client if we own it."""
|
||||||
|
if self._owns_http_client and self._http_client is not None:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
self._http_client = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> Gateway:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args: Any) -> None:
|
||||||
|
await self.aclose()
|
||||||
|
|
||||||
|
def _select_provider(self, task: str) -> Any:
|
||||||
|
"""Select the provider config for *task*.
|
||||||
|
|
||||||
|
Cheap tasks always use the primary provider (never fallback to paid).
|
||||||
|
Strong tasks may use fallback if primary fails.
|
||||||
|
"""
|
||||||
|
task_class = self.config.get_task_class(task)
|
||||||
|
# Cheap tasks: primary only, never fallback (especially not paid).
|
||||||
|
if task_class == TaskClass.CHEAP:
|
||||||
|
return self.config.primary
|
||||||
|
# Strong tasks: primary, with fallback available.
|
||||||
|
return self.config.primary
|
||||||
|
|
||||||
|
def _check_budget(self, task: str, prompt: str) -> None:
|
||||||
|
"""Check if the estimated token usage would exceed the budget.
|
||||||
|
|
||||||
|
Raises BudgetExceeded BEFORE any provider call is made.
|
||||||
|
We estimate input tokens as len(prompt) // 4 (rough heuristic) and
|
||||||
|
add the max output token budget. If the estimated total exceeds
|
||||||
|
the budget, we raise.
|
||||||
|
"""
|
||||||
|
budget = self.config.get_budget(task)
|
||||||
|
# Rough input token estimate: ~4 chars per token.
|
||||||
|
estimated_input_tokens = len(prompt) // 4
|
||||||
|
# If input alone exceeds budget, that is over-budget.
|
||||||
|
if estimated_input_tokens > budget:
|
||||||
|
raise BudgetExceeded(
|
||||||
|
f"Task '{task}' estimated input tokens ({estimated_input_tokens}) "
|
||||||
|
f"exceed budget ({budget}). Call aborted before provider request."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _write_telemetry(self, row: TelemetryRow) -> None:
|
||||||
|
"""Send telemetry to the sink if one is configured."""
|
||||||
|
if self._telemetry_sink is None:
|
||||||
|
return
|
||||||
|
result = self._telemetry_sink(row)
|
||||||
|
if asyncio.iscoroutine(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _call_provider(
|
||||||
|
self,
|
||||||
|
provider_config: Any,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Make an async HTTP call to the provider's chat completions endpoint.
|
||||||
|
|
||||||
|
Returns the raw JSON response dict.
|
||||||
|
"""
|
||||||
|
client = await self._get_http_client()
|
||||||
|
url = f"{provider_config.base_url.rstrip('/')}/chat/completions"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {provider_config.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
|
"max_tokens": self.config.get_budget(task),
|
||||||
|
}
|
||||||
|
# If a schema is expected, request JSON format.
|
||||||
|
body["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
|
response = await client.post(url, json=body, headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract the content from the response.
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if not choices:
|
||||||
|
raise ProviderError(f"Provider returned no choices for task '{task}'")
|
||||||
|
content = choices[0].get("message", {}).get("content", "{}")
|
||||||
|
usage = data.get("usage", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = json.loads(content)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ProviderError(
|
||||||
|
f"Provider returned non-JSON content for task '{task}': {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
# Attach usage info for telemetry.
|
||||||
|
parsed["_usage"] = {
|
||||||
|
"input_tokens": usage.get("prompt_tokens", 0),
|
||||||
|
"output_tokens": usage.get("completion_tokens", 0),
|
||||||
|
"model": data.get("model", model),
|
||||||
|
"provider": provider_config.name,
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
async def _call_with_retry(
|
||||||
|
self,
|
||||||
|
provider_config: Any,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
model: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Call provider with retry policy: max retries on 429/5xx, then
|
||||||
|
fallback provider (for strong tasks only), then raise.
|
||||||
|
"""
|
||||||
|
last_exc: Exception | None = None
|
||||||
|
for attempt in range(self.config.max_retries + 1):
|
||||||
|
try:
|
||||||
|
return await self._call_provider(provider_config, task, prompt, model)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
if status == 429 or 500 <= status < 600:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s returned %d for task '%s' (attempt %d/%d)",
|
||||||
|
provider_config.name,
|
||||||
|
status,
|
||||||
|
task,
|
||||||
|
attempt + 1,
|
||||||
|
self.config.max_retries + 1,
|
||||||
|
)
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except (httpx.RequestError, ProviderError) as exc:
|
||||||
|
last_exc = exc
|
||||||
|
logger.warning(
|
||||||
|
"Provider %s error for task '%s' (attempt %d/%d): %s",
|
||||||
|
provider_config.name,
|
||||||
|
task,
|
||||||
|
attempt + 1,
|
||||||
|
self.config.max_retries + 1,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
if attempt < self.config.max_retries:
|
||||||
|
await asyncio.sleep(2 ** attempt)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All retries exhausted. Try fallback for strong tasks.
|
||||||
|
task_class = self.config.get_task_class(task)
|
||||||
|
if (
|
||||||
|
task_class == TaskClass.STRONG
|
||||||
|
and self.config.fallback
|
||||||
|
and self.config.fallback.is_configured
|
||||||
|
and self.config.fallback is not provider_config
|
||||||
|
):
|
||||||
|
logger.info("Falling back to %s for task '%s'", self.config.fallback.name, task)
|
||||||
|
try:
|
||||||
|
return await self._call_provider(
|
||||||
|
self.config.fallback, task, prompt, self.config.strong_model
|
||||||
|
)
|
||||||
|
except Exception as fallback_exc:
|
||||||
|
raise ProviderError(
|
||||||
|
f"Both primary and fallback providers failed for task '{task}': "
|
||||||
|
f"primary={last_exc}, fallback={fallback_exc}"
|
||||||
|
) from fallback_exc
|
||||||
|
|
||||||
|
raise ProviderError(
|
||||||
|
f"Provider call failed for task '{task}' after {self.config.max_retries + 1} attempts: {last_exc}"
|
||||||
|
) from last_exc
|
||||||
|
|
||||||
|
async def run_task(
|
||||||
|
self,
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
schema: dict | None = None,
|
||||||
|
application_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Run an LLM task and return the parsed result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: task name (e.g. 'score', 'extract', 'cv_assist', 'cl_critique').
|
||||||
|
prompt: the input prompt text.
|
||||||
|
schema: optional JSON schema to validate the output against.
|
||||||
|
application_id: optional application id for telemetry correlation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed JSON dict from the LLM.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BudgetExceeded: if the estimated token usage exceeds the budget.
|
||||||
|
SchemaValidationError: if the output does not match the schema.
|
||||||
|
ProviderError: if the provider call fails after all retries.
|
||||||
|
"""
|
||||||
|
# Budget guard: raise BEFORE any call is made.
|
||||||
|
self._check_budget(task, prompt)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
if self.config.mock_mode:
|
||||||
|
# Mock mode: return deterministic canned output.
|
||||||
|
result = get_mock_output(task)
|
||||||
|
duration_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
model = self.config.get_model(task)
|
||||||
|
|
||||||
|
row = TelemetryRow(
|
||||||
|
task=task,
|
||||||
|
model=model,
|
||||||
|
provider="mock",
|
||||||
|
input_tokens=len(prompt) // 4,
|
||||||
|
output_tokens=0,
|
||||||
|
cost_usd=0.0,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
application_id=application_id,
|
||||||
|
mock=True,
|
||||||
|
)
|
||||||
|
await self._write_telemetry(row)
|
||||||
|
|
||||||
|
# Validate against schema if provided.
|
||||||
|
if schema is not None:
|
||||||
|
_validate_schema(result, schema)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Real mode: call provider with retry/fallback.
|
||||||
|
provider = self._select_provider(task)
|
||||||
|
model = self.config.get_model(task)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self._call_with_retry(provider, task, prompt, model)
|
||||||
|
except BudgetExceeded:
|
||||||
|
raise
|
||||||
|
except (ProviderError, SchemaValidationError):
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ProviderError(f"Unexpected error for task '{task}': {exc}") from exc
|
||||||
|
|
||||||
|
duration_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
usage = result.pop("_usage", {})
|
||||||
|
|
||||||
|
row = TelemetryRow(
|
||||||
|
task=task,
|
||||||
|
model=usage.get("model", model),
|
||||||
|
provider=usage.get("provider", provider.name),
|
||||||
|
input_tokens=usage.get("input_tokens", 0),
|
||||||
|
output_tokens=usage.get("output_tokens", 0),
|
||||||
|
cost_usd=None,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
application_id=application_id,
|
||||||
|
mock=False,
|
||||||
|
)
|
||||||
|
await self._write_telemetry(row)
|
||||||
|
|
||||||
|
# Validate against schema if provided.
|
||||||
|
if schema is not None:
|
||||||
|
_validate_schema(result, schema)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_schema(data: dict, schema: dict) -> None:
|
||||||
|
"""Validate *data* against *schema*. Raises SchemaValidationError on failure."""
|
||||||
|
try:
|
||||||
|
jsonschema_validate(data, schema)
|
||||||
|
except JsonSchemaValidationError as exc:
|
||||||
|
raise SchemaValidationError(f"Schema validation failed: {exc.message}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def run_task(
|
||||||
|
task: str,
|
||||||
|
prompt: str,
|
||||||
|
schema: dict | None = None,
|
||||||
|
config: GatewayConfig | None = None,
|
||||||
|
telemetry_sink: TelemetrySink | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Convenience function: create a Gateway, run one task, close it.
|
||||||
|
|
||||||
|
Useful for one-off calls. For repeated calls, instantiate Gateway directly.
|
||||||
|
"""
|
||||||
|
cfg = config or GatewayConfig.from_env()
|
||||||
|
gw = Gateway(cfg, telemetry_sink=telemetry_sink)
|
||||||
|
try:
|
||||||
|
return await gw.run_task(task, prompt, schema=schema)
|
||||||
|
finally:
|
||||||
|
await gw.aclose()
|
||||||
96
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
96
packages/llm-gateway/src/llm_gateway/mock.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Mock mode for the LLM gateway.
|
||||||
|
|
||||||
|
When no API key is configured, the gateway returns deterministic canned
|
||||||
|
outputs per task name. This allows the API and tests to run offline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
# Deterministic canned outputs per task name.
|
||||||
|
# Each entry is a dict that will be returned as the task result.
|
||||||
|
MOCK_OUTPUTS: dict[str, dict] = {
|
||||||
|
"score": {
|
||||||
|
"score": 75,
|
||||||
|
"rationale": {
|
||||||
|
"match": "good",
|
||||||
|
"reasons": ["skills align", "location matches"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"extract": {
|
||||||
|
"company": "Example Corp",
|
||||||
|
"title": "Software Engineer",
|
||||||
|
"location": "Stockholm",
|
||||||
|
"requirements": ["Python", "PostgreSQL", "Docker"],
|
||||||
|
},
|
||||||
|
"cv_assist": {
|
||||||
|
"suggestions": [
|
||||||
|
"Led a team of 5 developers to deliver a critical integration",
|
||||||
|
"Reduced API latency by 40% through caching and query optimization",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"cl_critique": {
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"quote": "I am a hard worker",
|
||||||
|
"suggestion": "Replace generic claim with a specific achievement metric",
|
||||||
|
"severity": "medium",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quote": "Dear Sir/Madam",
|
||||||
|
"suggestion": "Address the hiring manager by name if known",
|
||||||
|
"severity": "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"critique": {
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"quote": "sample text",
|
||||||
|
"suggestion": "improve clarity",
|
||||||
|
"severity": "low",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"research": {
|
||||||
|
"summary": "The company is a mid-size tech firm focused on cloud infrastructure.",
|
||||||
|
"key_points": ["Founded in 2015", "Series B funding", "Remote-first culture"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default mock output for unknown task names.
|
||||||
|
DEFAULT_MOCK_OUTPUT: dict = {
|
||||||
|
"result": "mock output",
|
||||||
|
"task": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_mock_output(task: str) -> dict:
|
||||||
|
"""Return a deterministic mock output for *task*.
|
||||||
|
|
||||||
|
For unknown tasks, returns DEFAULT_MOCK_OUTPUT with the task name filled in.
|
||||||
|
"""
|
||||||
|
if task in MOCK_OUTPUTS:
|
||||||
|
# Return a copy so callers cannot mutate the canned data.
|
||||||
|
return json.loads(json.dumps(MOCK_OUTPUTS[task]))
|
||||||
|
result = json.loads(json.dumps(DEFAULT_MOCK_OUTPUT))
|
||||||
|
result["task"] = task
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mock_telemetry_row(task: str, model: str) -> dict:
|
||||||
|
"""Build a mock telemetry row dict for offline mode."""
|
||||||
|
return {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"task": task,
|
||||||
|
"model": model,
|
||||||
|
"provider": "mock",
|
||||||
|
"input_tokens": 0,
|
||||||
|
"output_tokens": 0,
|
||||||
|
"cost_usd": 0.0,
|
||||||
|
"duration_ms": 0,
|
||||||
|
"mock": True,
|
||||||
|
}
|
||||||
491
packages/llm-gateway/tests/test_gateway.py
Normal file
491
packages/llm-gateway/tests/test_gateway.py
Normal file
|
|
@ -0,0 +1,491 @@
|
||||||
|
"""Tests for the LLM gateway package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from llm_gateway.config import GatewayConfig, ProviderConfig, TaskClass
|
||||||
|
from llm_gateway.exceptions import BudgetExceeded, SchemaValidationError
|
||||||
|
from llm_gateway.gateway import Gateway, TelemetryRow, run_task
|
||||||
|
from llm_gateway.mock import get_mock_output, MOCK_OUTPUTS
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Fixtures ----
|
||||||
|
|
||||||
|
|
||||||
|
def mock_config(**overrides: Any) -> GatewayConfig:
|
||||||
|
"""Build a config in mock mode (no API key)."""
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url="https://mock.example.com/v1",
|
||||||
|
api_key="",
|
||||||
|
model="glm-5.2",
|
||||||
|
)
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"primary": primary,
|
||||||
|
"fallback": None,
|
||||||
|
"cheap_model": "glm-5.2",
|
||||||
|
"strong_model": "glm-5.2",
|
||||||
|
"budgets": {"score": 2000, "extract": 4000, "default": 4000},
|
||||||
|
"max_retries": 2,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return GatewayConfig(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def real_config(**overrides: Any) -> GatewayConfig:
|
||||||
|
"""Build a config with a fake API key (non-mock mode)."""
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url="https://mock.example.com/v1",
|
||||||
|
api_key="fake-key-1234",
|
||||||
|
model="glm-5.2",
|
||||||
|
)
|
||||||
|
defaults: dict[str, Any] = {
|
||||||
|
"primary": primary,
|
||||||
|
"fallback": None,
|
||||||
|
"cheap_model": "glm-5.2",
|
||||||
|
"strong_model": "glm-5.2",
|
||||||
|
"budgets": {"score": 2000, "extract": 4000, "default": 4000},
|
||||||
|
"max_retries": 2,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return GatewayConfig(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Mock mode tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestMockMode:
|
||||||
|
async def test_mock_returns_deterministic_output(self) -> None:
|
||||||
|
"""Mock mode returns deterministic canned outputs per task."""
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
result_a = await gw.run_task("score", "Score this job")
|
||||||
|
result_b = await gw.run_task("score", "Score this job")
|
||||||
|
assert result_a == result_b
|
||||||
|
assert result_a["score"] == 75
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_mock_different_tasks_different_output(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
score_result = await gw.run_task("score", "prompt")
|
||||||
|
extract_result = await gw.run_task("extract", "prompt")
|
||||||
|
assert score_result != extract_result
|
||||||
|
assert "score" in score_result
|
||||||
|
assert "company" in extract_result
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_mock_unknown_task(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
result = await gw.run_task("unknown_task", "prompt")
|
||||||
|
assert result["result"] == "mock output"
|
||||||
|
assert result["task"] == "unknown_task"
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_mock_mode_property(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
assert config.mock_mode is True
|
||||||
|
|
||||||
|
async def test_real_mode_not_mock(self) -> None:
|
||||||
|
config = real_config()
|
||||||
|
assert config.mock_mode is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Schema validation tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaValidation:
|
||||||
|
async def test_schema_passes(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"score": {"type": "number"},
|
||||||
|
"rationale": {"type": "object"},
|
||||||
|
},
|
||||||
|
"required": ["score"],
|
||||||
|
}
|
||||||
|
result = await gw.run_task("score", "prompt", schema=schema)
|
||||||
|
assert "score" in result
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_schema_fails(self) -> None:
|
||||||
|
"""Schema validation failure should raise SchemaValidationError."""
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
# The mock output for 'score' has score=75 (number). We require a string,
|
||||||
|
# which should fail validation.
|
||||||
|
bad_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"score": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["score"],
|
||||||
|
}
|
||||||
|
with pytest.raises(SchemaValidationError):
|
||||||
|
await gw.run_task("score", "prompt", schema=bad_schema)
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_schema_missing_required_field(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config)
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["nonexistent_field"],
|
||||||
|
}
|
||||||
|
with pytest.raises(SchemaValidationError):
|
||||||
|
await gw.run_task("score", "prompt", schema=schema)
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Budget guard tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetGuard:
|
||||||
|
async def test_budget_exceeded_raises_before_call(self) -> None:
|
||||||
|
"""Over-budget prompt should raise BudgetExceeded before any call."""
|
||||||
|
config = mock_config(budgets={"score": 10})
|
||||||
|
gw = Gateway(config)
|
||||||
|
# 10 token budget, ~4 chars/token, so >40 chars should exceed.
|
||||||
|
long_prompt = "x" * 100
|
||||||
|
with pytest.raises(BudgetExceeded):
|
||||||
|
await gw.run_task("score", long_prompt)
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_budget_within_limit_does_not_raise(self) -> None:
|
||||||
|
config = mock_config(budgets={"score": 10000})
|
||||||
|
gw = Gateway(config)
|
||||||
|
result = await gw.run_task("score", "short prompt")
|
||||||
|
assert result["score"] == 75
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_budget_guard_in_real_mode(self) -> None:
|
||||||
|
"""Budget guard must raise before call even in real (non-mock) mode."""
|
||||||
|
config = real_config(budgets={"score": 10})
|
||||||
|
gw = Gateway(config)
|
||||||
|
with pytest.raises(BudgetExceeded):
|
||||||
|
await gw.run_task("score", "x" * 100)
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_default_budget_fallback(self) -> None:
|
||||||
|
"""Unknown task should use default budget."""
|
||||||
|
config = mock_config(budgets={"score": 2000, "default": 100})
|
||||||
|
gw = Gateway(config)
|
||||||
|
# Unknown task uses default=100, so >400 chars exceeds.
|
||||||
|
with pytest.raises(BudgetExceeded):
|
||||||
|
await gw.run_task("unknown_task", "x" * 500)
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Telemetry tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelemetry:
|
||||||
|
async def test_telemetry_sink_called_in_mock_mode(self) -> None:
|
||||||
|
sink_calls: list[TelemetryRow] = []
|
||||||
|
|
||||||
|
async def sink(row: TelemetryRow) -> None:
|
||||||
|
sink_calls.append(row)
|
||||||
|
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config, telemetry_sink=sink)
|
||||||
|
await gw.run_task("score", "test prompt")
|
||||||
|
assert len(sink_calls) == 1
|
||||||
|
assert sink_calls[0].task == "score"
|
||||||
|
assert sink_calls[0].mock is True
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_telemetry_sink_sync_callable(self) -> None:
|
||||||
|
"""Sync sinks should also work (no await needed)."""
|
||||||
|
sink_calls: list[TelemetryRow] = []
|
||||||
|
|
||||||
|
def sync_sink(row: TelemetryRow) -> None:
|
||||||
|
sink_calls.append(row)
|
||||||
|
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config, telemetry_sink=sync_sink)
|
||||||
|
await gw.run_task("score", "test prompt")
|
||||||
|
assert len(sink_calls) == 1
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_no_sink_no_error(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
gw = Gateway(config, telemetry_sink=None)
|
||||||
|
result = await gw.run_task("score", "prompt")
|
||||||
|
assert result["score"] == 75
|
||||||
|
await gw.aclose()
|
||||||
|
|
||||||
|
async def test_telemetry_row_to_dict(self) -> None:
|
||||||
|
row = TelemetryRow(task="score", model="glm-5.2", provider="mock")
|
||||||
|
d = row.to_dict()
|
||||||
|
assert d["task"] == "score"
|
||||||
|
assert d["model"] == "glm-5.2"
|
||||||
|
assert d["provider"] == "mock"
|
||||||
|
assert "id" in d
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Config tests ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestGatewayConfig:
|
||||||
|
def test_from_env_mock_mode(self) -> None:
|
||||||
|
env = {}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.mock_mode is True
|
||||||
|
assert config.primary.model == "glm-5.2"
|
||||||
|
|
||||||
|
def test_from_env_real_mode(self) -> None:
|
||||||
|
env = {
|
||||||
|
"LLM_PRIMARY_KEY": "test-key",
|
||||||
|
"LLM_PRIMARY_BASE_URL": "https://api.example.com/v1",
|
||||||
|
"LLM_PRIMARY_MODEL": "custom-model",
|
||||||
|
}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.mock_mode is False
|
||||||
|
assert config.primary.api_key == "test-key"
|
||||||
|
assert config.primary.model == "custom-model"
|
||||||
|
|
||||||
|
def test_from_env_ollama_key(self) -> None:
|
||||||
|
env = {"OLLAMA_API_KEY": "ollama-key-123"}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.mock_mode is False
|
||||||
|
assert config.primary.api_key == "ollama-key-123"
|
||||||
|
|
||||||
|
def test_from_env_budgets(self) -> None:
|
||||||
|
env = {"LLM_BUDGET_SCORE": "500"}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.budgets["score"] == 500
|
||||||
|
|
||||||
|
def test_task_class_routing(self) -> None:
|
||||||
|
config = mock_config()
|
||||||
|
assert config.get_task_class("score") == TaskClass.CHEAP
|
||||||
|
assert config.get_task_class("extract") == TaskClass.CHEAP
|
||||||
|
assert config.get_task_class("cv_assist") == TaskClass.CHEAP
|
||||||
|
assert config.get_task_class("critique") == TaskClass.STRONG
|
||||||
|
assert config.get_task_class("cl_critique") == TaskClass.STRONG
|
||||||
|
|
||||||
|
def test_get_model_routing(self) -> None:
|
||||||
|
config = mock_config(cheap_model="cheap-model", strong_model="strong-model")
|
||||||
|
assert config.get_model("score") == "cheap-model"
|
||||||
|
assert config.get_model("critique") == "strong-model"
|
||||||
|
|
||||||
|
def test_paid_fallback_detection(self) -> None:
|
||||||
|
"""Config should detect paid provider URLs."""
|
||||||
|
env = {
|
||||||
|
"LLM_PRIMARY_KEY": "key",
|
||||||
|
"LLM_FALLBACK_BASE_URL": "https://api.openai.com/v1",
|
||||||
|
"LLM_FALLBACK_KEY": "fb-key",
|
||||||
|
}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.fallback is not None
|
||||||
|
assert config.fallback.is_paid is True
|
||||||
|
|
||||||
|
def test_paid_fallback_not_used_for_cheap(self) -> None:
|
||||||
|
"""The gateway must not route cheap tasks to paid fallback.
|
||||||
|
|
||||||
|
We verify this by checking _select_provider returns primary for cheap.
|
||||||
|
"""
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary", base_url="https://a.com/v1", api_key="k", model="m"
|
||||||
|
)
|
||||||
|
fallback = ProviderConfig(
|
||||||
|
name="fallback",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_key="k2",
|
||||||
|
model="m2",
|
||||||
|
is_paid=True,
|
||||||
|
)
|
||||||
|
config = GatewayConfig(primary=primary, fallback=fallback)
|
||||||
|
gw = Gateway(config)
|
||||||
|
provider = gw._select_provider("score")
|
||||||
|
assert provider.name == "primary"
|
||||||
|
# The _select_provider method enforces this by always returning primary
|
||||||
|
# for cheap tasks, never the paid fallback.
|
||||||
|
|
||||||
|
def test_non_paid_fallback(self) -> None:
|
||||||
|
"""Non-paid (e.g. ollama) fallback is fine."""
|
||||||
|
env = {
|
||||||
|
"LLM_PRIMARY_KEY": "key",
|
||||||
|
"LLM_FALLBACK_BASE_URL": "https://api.ollama-cloud.com/v1",
|
||||||
|
"LLM_FALLBACK_KEY": "fb-key",
|
||||||
|
}
|
||||||
|
config = GatewayConfig.from_env(env=env)
|
||||||
|
assert config.fallback is not None
|
||||||
|
assert config.fallback.is_paid is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Provider call tests (with mocked HTTP) ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderCalls:
|
||||||
|
async def test_real_mode_calls_provider(self) -> None:
|
||||||
|
"""In non-mock mode, the gateway should make an HTTP call."""
|
||||||
|
config = real_config()
|
||||||
|
mock_response_data = {
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"content": json.dumps({"score": 85, "rationale": {"ok": True}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 50, "completion_tokens": 30},
|
||||||
|
"model": "glm-5.2",
|
||||||
|
}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=mock_response_data)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
base_url="https://mock.example.com",
|
||||||
|
)
|
||||||
|
gw = Gateway(config, http_client=client)
|
||||||
|
result = await gw.run_task("score", "Score this job")
|
||||||
|
assert result["score"] == 85
|
||||||
|
assert "_usage" not in result # _usage should be popped
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def test_retry_on_429(self) -> None:
|
||||||
|
"""Gateway should retry on 429 then succeed."""
|
||||||
|
config = real_config(max_retries=2)
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count < 2:
|
||||||
|
return httpx.Response(429, json={"error": "rate limited"})
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [
|
||||||
|
{"message": {"content": json.dumps({"score": 50})}}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
base_url="https://mock.example.com",
|
||||||
|
)
|
||||||
|
gw = Gateway(config, http_client=client)
|
||||||
|
result = await gw.run_task("score", "test")
|
||||||
|
assert result["score"] == 50
|
||||||
|
assert call_count == 2
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def test_retry_exhausted_raises(self) -> None:
|
||||||
|
"""After all retries, ProviderError should be raised."""
|
||||||
|
config = real_config(max_retries=1)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, json={"error": "server error"})
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
base_url="https://mock.example.com",
|
||||||
|
)
|
||||||
|
from llm_gateway.exceptions import ProviderError
|
||||||
|
|
||||||
|
gw = Gateway(config, http_client=client)
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
await gw.run_task("critique", "test")
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def test_fallback_used_for_strong_task(self) -> None:
|
||||||
|
"""Strong tasks should fall back when primary fails."""
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url="https://primary.example.com/v1",
|
||||||
|
api_key="pk",
|
||||||
|
model="glm-5.2",
|
||||||
|
)
|
||||||
|
fallback = ProviderConfig(
|
||||||
|
name="fallback",
|
||||||
|
base_url="https://fallback.example.com/v1",
|
||||||
|
api_key="fk",
|
||||||
|
model="glm-5.2",
|
||||||
|
is_paid=False,
|
||||||
|
)
|
||||||
|
config = GatewayConfig(
|
||||||
|
primary=primary,
|
||||||
|
fallback=fallback,
|
||||||
|
budgets={"critique": 4000, "default": 4000},
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if "primary.example.com" in str(request.url):
|
||||||
|
return httpx.Response(500, json={"error": "primary down"})
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"choices": [
|
||||||
|
{"message": {"content": json.dumps({"comments": []})}}
|
||||||
|
],
|
||||||
|
"usage": {"prompt_tokens": 20, "completion_tokens": 10},
|
||||||
|
"model": "glm-5.2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
gw = Gateway(config, http_client=client)
|
||||||
|
result = await gw.run_task("critique", "review this")
|
||||||
|
assert result == {"comments": []}
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
async def test_no_fallback_for_cheap_task(self) -> None:
|
||||||
|
"""Cheap tasks must not use fallback even when primary fails."""
|
||||||
|
primary = ProviderConfig(
|
||||||
|
name="primary",
|
||||||
|
base_url="https://primary.example.com/v1",
|
||||||
|
api_key="pk",
|
||||||
|
model="glm-5.2",
|
||||||
|
)
|
||||||
|
fallback = ProviderConfig(
|
||||||
|
name="fallback",
|
||||||
|
base_url="https://fallback.example.com/v1",
|
||||||
|
api_key="fk",
|
||||||
|
model="glm-5.2",
|
||||||
|
)
|
||||||
|
config = GatewayConfig(
|
||||||
|
primary=primary,
|
||||||
|
fallback=fallback,
|
||||||
|
budgets={"score": 4000, "default": 4000},
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(500, json={"error": "down"})
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
from llm_gateway.exceptions import ProviderError
|
||||||
|
|
||||||
|
gw = Gateway(config, http_client=client)
|
||||||
|
with pytest.raises(ProviderError):
|
||||||
|
await gw.run_task("score", "score this")
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Convenience function test ----
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTaskFunction:
|
||||||
|
async def test_run_task_convenience_mock(self) -> None:
|
||||||
|
result = await run_task("score", "test", config=mock_config())
|
||||||
|
assert result["score"] == 75
|
||||||
Loading…
Reference in a new issue