Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
30 KiB
MAIN PROMPT: "Build me a private, self-hosted AI search API called "Axon" — a better alternative to Tavily. It should be a FastAPI backend that I can run on my Linux server.
Stack
- Python 3.11+
- FastAPI
- trafilatura (content extraction)
- httpx (async HTTP)
- sentence-transformers with BAAI/bge-reranker-v2-m3 (reranking)
- redis (caching)
- pydantic v2 (schemas)
- uvicorn (server)
Search Sources
Implement all three and merge results, deduplicating by URL:
- Brave Search API (primary) — via BRAVE_API_KEY env var
- DuckDuckGo — using the duckduckgo-search Python package, no key needed
- Serper.dev (fallback/secondary) — via SERPER_API_KEY env var, only called if Brave returns fewer than 5 results
Project Structure
axon/ ├── main.py ├── config.py ├── models.py ├── routers/ │ ├── search.py │ ├── extract.py │ └── research.py ├── services/ │ ├── sources/ │ │ ├── brave.py │ │ ├── ddg.py │ │ └── serper.py │ ├── scraper.py │ ├── reranker.py │ ├── synthesizer.py │ └── cache.py ├── middleware/ │ └── auth.py ├── .env.example ├── requirements.txt └── README.md
Endpoints
POST /search
Main search endpoint. Request body: { "query": "string", "max_results": 10, // default 10, max 20 "search_depth": "basic" | "advanced", // basic = no scraping, advanced = full scrape + rerank "include_answer": false, // if true, synthesize an answer via LLM "include_raw_content": false, "domains_whitelist": [], // only return results from these domains "domains_blacklist": [], // never return results from these domains "topic": "general" | "news", "time_range": "day" | "week" | "month" | "year" | null }
Response: { "query": "string", "answer": "string | null", "results": [ { "title": "string", "url": "string", "content": "string", // snippet or extracted text "score": 0.95, // relevance score 0-1 "published_date": "string | null", "favicon": "string | null" } ], "response_time": 1.23, "credits_used": 1 }
POST /extract
Extract clean content from a URL or list of URLs. Request body: { "urls": ["https://example.com"], "include_images": false }
Response: { "results": [ { "url": "string", "title": "string", "content": "string", "success": true, "error": null } ] }
POST /research
Deep research endpoint. Given a topic, auto-generate 3-5 sub-queries, run /search on each, deduplicate, rerank everything together, synthesize a final report. Request body: { "query": "string", "depth": "basic" | "deep", // basic = 3 sub-queries, deep = 5 sub-queries "output_format": "markdown" | "json" }
Response: { "query": "string", "report": "string", // markdown or JSON report "sources": [...], // all sources used "sub_queries": [...], // the generated sub-queries "response_time": 12.4 }
GET /health
Returns server status, redis connection status, reranker model load status.
Core Logic Details
Search Flow (advanced depth)
- Query all enabled sources concurrently with asyncio.gather
- Merge and deduplicate results by URL
- For each result URL, scrape full content using trafilatura (async, with 5s timeout per URL, max 10 concurrent scrapes using asyncio.Semaphore)
- Chunk content to max 512 tokens per chunk
- Run bge-reranker-v2-m3 to score all chunks against the original query
- Take the top-scoring chunk per URL as that result's content
- Sort results by score descending
- If include_answer is true, send top 5 results as context to LLM via LITELLM_BASE_URL + LITELLM_API_KEY and get a synthesized answer
Search Flow (basic depth)
- Same as above steps 1-2 (multi-source fetch + dedup)
- Skip scraping — use the snippet returned by the search APIs directly
- Still run reranker on snippets
- Return results
Caching
- Use Redis to cache search results for 10 minutes (key = hash of query + params)
- Cache extracted content per URL for 1 hour
- If REDIS_URL is not set, fall back to an in-memory dict cache
Auth
- API key auth via X-API-Key header
- Keys stored in API_KEYS env var as a comma-separated list
- If API_KEYS is not set, skip auth entirely (dev mode)
LLM Synthesis
- Call LLM via LITELLM_BASE_URL + LITELLM_API_KEY (standard OpenAI-compatible format)
- Model set via SYNTHESIS_MODEL env var, default "claude-sonnet-4-6"
- System prompt: "You are a precise research assistant. Answer the user's query based only on the provided search results. Cite sources using [1], [2] notation. Be concise and factual."
Config (.env.example)
BRAVE_API_KEY= SERPER_API_KEY= REDIS_URL=redis://localhost:6379 API_KEYS=your-secret-key-here LITELLM_BASE_URL=http://localhost:4000 LITELLM_API_KEY= SYNTHESIS_MODEL=claude-sonnet-4-6 RERANKER_MODEL=BAAI/bge-reranker-v2-m3 PORT=8000 MAX_CONCURRENT_SCRAPES=10 SCRAPE_TIMEOUT=5
Error Handling
- Every endpoint must return proper HTTP status codes
- Scraping failures for individual URLs should be silently skipped, not crash the request
- Source API failures (Brave down, etc.) should be caught and logged, fall through to remaining sources
- Return 503 only if ALL sources fail
Requirements
- All I/O must be fully async
- The reranker model should be loaded once at startup into memory, not per-request
- Log all requests with query, source count, scrape count, rerank score range, and response time
- Add CORS middleware allowing all origins
- Write a clean README with setup instructions and example curl commands for all endpoints"
Axon Search API — Complete Project Reference
What This Is
Axon is a self-hosted, private AI search API — built as a better alternative to Tavily. It is a FastAPI backend meant to run on a Linux server. It aggregates results from multiple search engines, scrapes full page content, reranks with a local ML model, and optionally synthesizes an AI-written answer using any OpenAI-compatible LLM endpoint.
The goal: POST /search with a query → get back ranked, cleaned, optionally AI-answered results. No rate limits you don't control. No external service owning your queries.
How It Is Being Built
The project is being implemented via Subagent-Driven Development (one subagent per task, reviewed after each). The implementation plan lives at:
docs/superpowers/plans/2026-06-18-axon-search-api.md
Progress ledger (survives context compression):
.git/sdd/progress.md
Overall Architecture
Request
│
▼
APIKeyMiddleware ← checks X-API-Key header
│
├── POST /search ← routers/search.py
│ │
│ ├── get_cache() check Redis / in-memory
│ ├── search_brave() ──┐
│ ├── search_ddg() ──┼── asyncio.gather (concurrent)
│ └── search_serper() ─┘ (serper only if brave < 5 results)
│ │
│ ├── deduplicate by URL
│ ├── filter whitelist/blacklist
│ ├── scrape_urls() [if advanced depth]
│ ├── rerank_results() ← CrossEncoder scores all chunks
│ ├── synthesize_answer() [if include_answer=True]
│ └── store in cache, return
│
├── POST /extract ← routers/extract.py [NOT BUILT YET]
│ └── per-URL: httpx fetch → trafilatura extract → cache 1h
│
├── POST /research ← routers/research.py [NOT BUILT YET]
│ ├── generate_sub_queries() via LLM
│ ├── run /search for each sub-query
│ ├── deduplicate all results
│ └── synthesize_report()
│
└── GET /health ← main.py [NOT BUILT YET]
└── returns redis status + reranker load status
Current Build Status
✅ DONE — 10 of 14 Tasks
All of the following have been implemented, reviewed by a subagent, and fixed where needed.
Task 1 — Project Scaffold ✅
Commits: 556b771..176e389
Files created:
requirements.txt— all pip dependencies.env.example— all configurable env vars with defaults.gitignoreaxon/__init__.py,axon/middleware/__init__.py,axon/routers/__init__.py,axon/services/__init__.py,axon/services/sources/__init__.pytests/__init__.pyaxon/config.py—Settingsclass andsettingssingleton
axon/config.py — the full thing, verbatim:
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
brave_api_key: Optional[str] = None
serper_api_key: Optional[str] = None
redis_url: Optional[str] = "redis://localhost:6379"
api_keys: Optional[str] = None
litellm_base_url: str = "http://localhost:4000"
litellm_api_key: Optional[str] = None
synthesis_model: str = "claude-sonnet-4-6"
reranker_model: str = "BAAI/bge-reranker-v2-m3"
port: int = 8000
max_concurrent_scrapes: int = 10
scrape_timeout: int = 5
@property
def api_key_list(self) -> list[str]:
if not self.api_keys:
return []
return [k.strip() for k in self.api_keys.split(",") if k.strip()]
model_config = {"env_file": ".env", "extra": "ignore"}
settings = Settings()
Task 2 — Pydantic Models ✅
Commits: 176e389..b8e6a51
File: axon/models.py
All 8 schemas. Key ones:
class SearchRequest(BaseModel):
query: str
max_results: int = Field(default=10, ge=1, le=20)
search_depth: Literal["basic", "advanced"] = "basic"
include_answer: bool = False
include_raw_content: bool = False
domains_whitelist: list[str] = []
domains_blacklist: list[str] = []
topic: Literal["general", "news"] = "general"
time_range: Optional[Literal["day", "week", "month", "year"]] = None
class SearchResult(BaseModel):
title: str
url: str
content: str
score: float = 0.5
published_date: Optional[str] = None
favicon: Optional[str] = None
class SearchResponse(BaseModel):
query: str
answer: Optional[str] = None
results: list[SearchResult]
response_time: float
credits_used: int = 1
class ExtractRequest(BaseModel):
urls: list[str]
include_images: bool = False
class ExtractResult(BaseModel):
url: str
title: Optional[str] = None
content: str = ""
success: bool
error: Optional[str] = None
class ExtractResponse(BaseModel):
results: list[ExtractResult]
class ResearchRequest(BaseModel):
query: str
depth: Literal["basic", "deep"] = "basic"
output_format: Literal["markdown", "json"] = "markdown"
class ResearchResponse(BaseModel):
query: str
report: str
sources: list[SearchResult]
sub_queries: list[str]
response_time: float
Task 3 — Cache Service ✅
Commits: b8e6a51..200085c
File: axon/services/cache.py
Two backends with identical async interface:
InMemoryCache— dict with TTL timestamps. Used when Redis is unavailable orREDIS_URLunset.RedisCache— wrapsredis.asyncioclient. Usessetexfor TTL.
Key functions:
async def get_cache() -> InMemoryCache | RedisCache:
# Singleton. Tries Redis on first call; falls back to in-memory on failure.
def make_cache_key(prefix: str, **kwargs) -> str:
# SHA-256 of JSON-serialized kwargs. Returns "prefix:<hex>"
# Example: make_cache_key("search", query="hello") → "search:abc123..."
TTLs used by callers (not enforced here):
- Search results: 600 seconds (10 minutes)
- Extracted content: 3600 seconds (1 hour)
Task 4 — Brave Search Source ✅
Commits: 200085c..5da0eb9
File: axon/services/sources/brave.py
async def search_brave(
query: str,
count: int = 10,
topic: str = "general",
time_range: str | None = None,
) -> list[SearchResult]:
- Returns
[]immediately ifsettings.brave_api_keyis falsy - Returns
[]on any HTTP/network error (never raises) topic="news"→ hits/res/v1/news/search; else/res/v1/web/search- Freshness param map:
day→pd,week→pw,month→pm,year→py - Header:
X-Subscription-Token - Parses
descriptionfield (falls back toextra_snippets[0]) for content - Provides
faviconfrommeta_url.favicon
Task 5 — DDG + Serper Sources ✅
Commits: 5da0eb9..5a13863
Files: axon/services/sources/ddg.py, axon/services/sources/serper.py
DDG:
async def search_ddg(query, count=10, topic="general", time_range=None) -> list[SearchResult]:
- Uses
duckduckgo-searchpackage viaasyncio.to_thread()(blocking call) - Text results: url =
item["href"], content =item["body"] - News results: url =
item["url"], content =item["body"] - Timelimit map:
day→d,week→w,month→m,year→y - No API key needed
Serper:
async def search_serper(query, count=10, topic="general", time_range=None) -> list[SearchResult]:
- Returns
[]ifsettings.serper_api_keyis falsy topic="news"→https://google.serper.dev/news; else/search- TBS map:
day→qdr:d,week→qdr:w,month→qdr:m,year→qdr:y - Header:
X-API-KEY - Parses
organicornewskey in response countcapped at 10 for Serper (their limit per call)
Task 6 — Scraper Service ✅ (+ SSRF Fix)
Commits: 5a13863..aaa7ffa, then SSRF fix at 42216c4
File: axon/services/scraper.py
async def scrape_url(url: str, timeout: int = 5) -> str | None:
# Validates URL first (SSRF check), then fetches, then extracts with trafilatura
# Returns None on any failure
async def scrape_urls(
urls: list[str],
max_concurrent: int = 10,
timeout: int = 5,
) -> dict[str, str | None]:
# Concurrent batch scrape with asyncio.Semaphore
# Individual failures become None values in the dict
SSRF Protection (added mid-build after automated security review flagged it):
_is_safe_url(url)runs before every fetch- Checks scheme is
httporhttpsonly - Resolves hostname via
socket.getaddrinfo, rejects any result that is:- Loopback (127.x.x.x, ::1)
- Private (10.x, 172.16-31.x, 192.168.x)
- Link-local (169.254.x.x — blocks AWS metadata at 169.254.169.254)
- Multicast, reserved, or unspecified
follow_redirects=False(redirects to internal IPs are a common SSRF bypass)- trafilatura called via
asyncio.to_thread()(CPU-bound extraction)
Task 7 — Reranker Service ✅
Commits: aaa7ffa..acc7a3c
File: axon/services/reranker.py
_model = None # loaded at startup, stays in memory
def load_reranker() -> None:
# Loads BAAI/bge-reranker-v2-m3 CrossEncoder into _model
# ~1GB download on first run, cached by HuggingFace after that
def get_reranker() -> "CrossEncoder | None":
return _model
def chunk_text(text: str, max_tokens: int = 512) -> list[str]:
# Splits into chunks of ~384 words (512 tokens × 0.75 words/token)
# Empty string → [""]
async def rerank_results(
query: str,
contents: list[tuple[str, str]], # (url, text) pairs
) -> list[tuple[str, str, float]]:
# For each URL: chunks text, scores all chunks vs query
# Picks best chunk per URL (argmax)
# Sigmoid-normalizes raw CrossEncoder logits to [0, 1]
# Returns sorted list of (url, best_chunk, score) — descending
# model.predict() runs via asyncio.to_thread() (blocking/CPU)
Score normalization: sigmoid(x) = 1 / (1 + exp(-x)) — maps CrossEncoder logits to 0–1 range.
Task 8 — Synthesizer Service ✅
Commits: acc7a3c..6d9a057
File: axon/services/synthesizer.py
All LLM calls go to settings.litellm_base_url + "/chat/completions" (standard OpenAI format). Authorization: Bearer <key> header is only set if settings.litellm_api_key is non-empty.
System prompt used for all synthesis:
"You are a precise research assistant. Answer the user's query based only on the provided search results. Cite sources using [1], [2] notation. Be concise and factual."
async def synthesize_answer(query: str, results: list[SearchResult]) -> str:
# Uses top 5 results as context
# Returns "No search results available..." if results is empty
# Returns "Answer synthesis failed: <error>" on LLM error
async def generate_sub_queries(query: str, count: int = 3) -> list[str]:
# Asks LLM to output exactly `count` queries, one per line
# On LLM error: returns [query, f"{query} overview", f"{query} recent developments"][:count]
async def synthesize_report(query, results, output_format="markdown") -> str:
# Uses top 20 results as context
# markdown format → instructs "## Summary, ## Key Findings, ## Conclusion"
# json format → instructs keys: summary, key_findings, conclusions
# Returns "Report synthesis failed: <error>" on LLM error
# 60s timeout (longer than answer synthesis at 30s)
Task 9 — Auth Middleware ✅
Commits: 6d9a057..bc49aa1
File: axon/middleware/auth.py
_EXEMPT = {"/health", "/docs", "/openapi.json", "/redoc"}
class APIKeyMiddleware(BaseHTTPMiddleware):
# If settings.api_key_list is empty → pass everything through (dev mode)
# If path in _EXEMPT → pass through regardless
# Otherwise → require X-API-Key header matching one of the keys
# Invalid/missing → 401 {"detail": "Invalid or missing API key"}
api_key_list is a @property on Settings that computes from settings.api_keys (a raw comma-separated string). When monkeypatching in tests, patch settings.api_keys (the underlying field), not api_key_list (the property).
Task 10 — Search Router ✅ (commit done, review pending)
Commit: 4193bba
File: axon/routers/search.py
Exports deduplicate_results() — used by the research router (Task 12).
Full search flow, in order:
- Build cache key from all request params → check cache; return immediately if hit
search_brave+search_ddgviaasyncio.gather(concurrent)- If
len(brave_results) < 5: also callsearch_serper - Merge all:
brave + ddg + serper - If all empty: raise
HTTPException(503) deduplicate_results()→ keep first URL occurrence_filter()→ applydomains_whitelist/domains_blacklist- Slice to
max_results - If
search_depth == "advanced":scrape_urls()→ replace result.content with scraped text where available rerank_results(query, [(url, content), ...])→ updates scores- Sort by score descending
- If not
include_raw_content: truncate content to 500 chars - If
include_answer:synthesize_answer() - Log:
query, source_count, scrape_count, [min_score, max_score], response_time - Store response in cache (TTL=600), return
❌ NOT DONE — Tasks 11–14
These four files do not exist yet:
Task 11 — Extract Router ❌
File to create: axon/routers/extract.py
Endpoint: POST /extract
What it needs to do:
- Accept
ExtractRequest(urls: list[str], include_images: bool) - For each URL: check cache (key=
"extract:<sha256 of url>", TTL=3600), fetch if miss - Fetch via httpx, extract text with
trafilatura.extract()viaasyncio.to_thread() - Also try
trafilatura.extract_metadata()to get title - Individual URL failures:
ExtractResult(success=False, error="...") - Return
ExtractResponse(results=[...]) - All URLs processed concurrently via
asyncio.gather - Cache successful extractions for 1 hour
Task 12 — Research Router ❌
File to create: axon/routers/research.py
Endpoint: POST /research
What it needs to do:
- Accept
ResearchRequest(query, depth, output_format) depth="basic"→ 3 sub-queries;depth="deep"→ 5 sub-queries- Call
generate_sub_queries(query, count)from synthesizer - Run
search()fromrouters/search.pyfor each sub-query (withsearch_depth="advanced",include_raw_content=True) - Sub-query searches run concurrently via
asyncio.gather(..., return_exceptions=True) - Failed sub-query searches are skipped (logged, not crashed)
- Merge all results →
deduplicate_results() - Call
synthesize_report(query, all_results, output_format) - Return
ResearchResponse(query, report, sources, sub_queries, response_time)
Import note: from axon.routers.search import search, deduplicate_results — calling the Python function directly, not making an HTTP request.
Task 13 — Main App + Health ❌
File to create: axon/main.py
What it needs to do:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@asynccontextmanager
async def lifespan(app):
load_reranker() # loads BAAI/bge-reranker-v2-m3 once at startup
yield
app = FastAPI(title="Axon Search API", version="1.0.0", lifespan=lifespan)
# CORS: allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
# Auth: add APIKeyMiddleware
# Routers: include search.router, extract.router, research.router
@app.get("/health")
async def health():
cache = await get_cache()
return {
"status": "ok",
"redis": "connected" | "in-memory fallback",
"reranker": "loaded" | "not loaded",
}
To run the server:
uvicorn axon.main:app --host 0.0.0.0 --port 8000
Task 14 — README ❌
File to create: README.md
Must include: setup steps, env var table, curl examples for all 4 endpoints (search, extract, research, health), source fallback logic explanation, performance notes.
File Tree — Current State
/srv/Axon/
├── .env.example
├── .gitignore
├── CLAUDE.md ← this file
├── requirements.txt
├── docs/
│ └── superpowers/plans/
│ └── 2026-06-18-axon-search-api.md
├── axon/
│ ├── __init__.py
│ ├── config.py ✅
│ ├── models.py ✅
│ ├── main.py ❌ MISSING
│ ├── middleware/
│ │ ├── __init__.py
│ │ └── auth.py ✅
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── search.py ✅
│ │ ├── extract.py ❌ MISSING
│ │ └── research.py ❌ MISSING
│ └── services/
│ ├── __init__.py
│ ├── cache.py ✅
│ ├── reranker.py ✅
│ ├── scraper.py ✅ (includes SSRF protection)
│ ├── synthesizer.py ✅
│ └── sources/
│ ├── __init__.py
│ ├── brave.py ✅
│ ├── ddg.py ✅
│ └── serper.py ✅
└── tests/
├── __init__.py
├── test_config.py ✅ 3 tests
├── test_models.py ✅ 6 tests
├── test_cache.py ✅ 7 tests
├── test_sources.py ✅ 10 tests (brave + ddg + serper)
├── test_scraper.py ✅ 4 tests
├── test_reranker.py ✅ 7 tests
├── test_synthesizer.py ✅ 4 tests
├── test_auth.py ✅ 5 tests
├── test_search_router.py ✅ 4 tests
├── test_extract_router.py ❌ MISSING
└── test_research_router.py ❌ MISSING (covered in test_search_router.py per plan)
Total tests passing right now: 50
Environment & Running Tests
# The venv is at /srv/Axon/venv (Python 3.14)
source /srv/Axon/venv/bin/activate
# Run all tests
python -m pytest tests/ -v
# Run a specific file
python -m pytest tests/test_search_router.py -v
# pytest-asyncio version: 1.4.0 (strict mode)
# ALL async tests must be decorated with @pytest.mark.asyncio
Known warnings (not errors):
- Starlette currently emits a
TestClientdeprecation warning about itshttpxintegration.
Environment Variables (from .env.example)
| Variable | Required | Default | Purpose |
|---|---|---|---|
BRAVE_API_KEY |
No | — | Enables Brave search source |
SERPER_API_KEY |
No | — | Enables Serper fallback source |
REDIS_URL |
No | redis://localhost:6379 |
Cache backend; omit for in-memory |
API_KEYS |
No | — | Comma-separated auth keys; unset = no auth |
LITELLM_BASE_URL |
No | http://localhost:4000 |
OpenAI-compatible LLM endpoint |
LITELLM_API_KEY |
No | — | Bearer token for LLM endpoint |
SYNTHESIS_MODEL |
No | claude-sonnet-4-6 |
Model used for answer/report synthesis |
RERANKER_MODEL |
No | BAAI/bge-reranker-v2-m3 |
HuggingFace CrossEncoder model |
PORT |
No | 8000 |
uvicorn listen port |
MAX_CONCURRENT_SCRAPES |
No | 10 |
asyncio.Semaphore limit for scraping |
SCRAPE_TIMEOUT |
No | 5 |
Per-URL timeout in seconds |
Coding Rules (enforced across all tasks)
- Python 3.11+ types only —
str | NonenotOptional[str],list[T]notList[T],dict[K,V]notDict[K,V]. No imports fromtypingfor these. - Pydantic v2 —
model_dump()andmodel_dump_json()only. Never.dict()or.json(). - All I/O is async —
httpx.AsyncClient,redis.asyncio,asyncio.to_thread()for blocking calls. - Never raise from service functions — sources, scraper, synthesizer all catch all exceptions and return empty/None/error strings.
- Tests use
@pytest.mark.asyncio— no anyio markers. - No
Optionalimports — this kept appearing in subagent output and was fixed each time.
Gotchas & Lessons Learned During Build
@pytest.mark.anyiovsasyncio— subagents kept usinganyio. Every test file was checked for this. Always@pytest.mark.asyncio.Optional[str]vsstr | None— the plan's own code examples hadOptionalin them (for illustration). Subagents copied it. Had to fix in Tasks 3, 6.importlib.reloadin tests — one subagent used it to reset module state for monkeypatching. Fragile and banned. Replaced with directmonkeypatch.setattr().- SSRF — automated security review flagged
scraper.pyafter Task 6's commit. Fixed in Task 7's commit window by adding_is_safe_url()with full IP blocklist. - Review of auth tests — reviewer incorrectly flagged monkeypatching
settings.api_keysas wrong (said it should patchapi_key_list). Butapi_key_listis a@property— you can't patch it directly. Patching the underlying field is correct. Confirmed all 5 tests pass. - Venv path — the project uses
/srv/Axon/venv. pytest-asyncio there is version 1.4.0. Mustsource venv/bin/activatebefore running any Python commands.
Current Final State
Tasks 11-14 are now complete:
axon/routers/extract.pyimplementsPOST /extractwith per-URL caching, trafilatura extraction, metadata title extraction, concurrent processing, and SSRF protection via the scraper URL safety check.axon/routers/research.pyimplementsPOST /researchby generating sub-queries, running advanced searches concurrently, skipping failed sub-searches, deduplicating sources, and synthesizing a final report.axon/main.pycreates the runnable FastAPI app with CORS, API key middleware, all routers, lifespan reranker loading, andGET /health.README.mddocuments setup, configuration, endpoint curl examples, source fallback behavior, caching, and performance notes.
Additional cleanup:
axon/config.pyandaxon/models.pynow usestr | Nonestyle types instead ofOptional[...].axon/services/cache.pynow uses timezone-aware UTC datetimes, removing the Python 3.14datetime.utcnow()warning.axon/services/sources/ddg.pynow prefers the currentddgspackage and falls back toduckduckgo_search;ddgs>=9.14.0is inrequirements.txt.axon/services/reranker.pyserializes model prediction and caps reranking to 4 chunks per document. This prevents/researchfrom generating unbounded cross-encoder work on huge pages.axon/routers/research.pyruns sub-searches sequentially with 5 results per sub-query. This avoids OOM on the current server while preserving deep multi-query research behavior.axon/middleware/rate_limit.pyadds per-key/IP rate limiting.axon/middleware/request_context.pyaddsX-Request-IDand records lightweight metrics.axon/routers/crawl.pyandaxon/routers/map.pyadd Tavily-style bounded same-site crawl/map endpoints.axon/services/scraper.pynow has reusable safe fetch, extraction, URL normalization, and same-site link discovery helpers with retries.axon/services/index.pyadds a private SQLite FTS index;POST /ingest,POST /index/search, and normal/searchcan use private indexed content.axon/services/jobs.pyandaxon/routers/jobs.pyadd in-process background jobs for research/crawl/map.axon/routers/tavily.pyadds/tavily/search,/tavily/extract,/tavily/crawl, and/tavily/mapcompatibility routes.axon/routers/validate.pyand citation helpers add basic citation coverage validation.- Browser extraction fallback is optional via
ENABLE_BROWSER_FALLBACK=trueif Playwright is installed.
Systemd deployment completed:
- Unit file:
/etc/systemd/system/axon.service - Enabled: yes
- Runtime env file:
/srv/Axon/.env(ignored by git, mode600) - LiteLLM model configured:
AR/openai/gpt-oss-120b:free - Service command:
/srv/Axon/venv/bin/uvicorn axon.main:app --host 0.0.0.0 --port 8000
Live endpoint verification completed through systemd:
GET /health-> 200, Redis connected, reranker loadedGET /openapi.json-> 200, paths include/extract,/health,/metrics,/crawl,/map,/research,/searchPOST /extractwithhttps://example.com-> 200POST /searchbasic with answer synthesis -> 200 using LiteLLMPOST /searchadvanced -> 200, completed in about 28 secondsPOST /researchbasic markdown -> 200, 15 sources, completed in about 163 seconds
Operational note:
- The BGE reranker is memory-heavy. Before reranker chunk capping,
/researchOOM-killed the service at roughly 4.2 GB. After capping and sequential research searches, the tested/researchrun peaked around 2 GB and completed successfully.
The git branch is master. The project is runnable with:
source /srv/Axon/venv/bin/activate
uvicorn axon.main:app --host 0.0.0.0 --port 8000
To run a full test suite health check right now:
source /srv/Axon/venv/bin/activate && python -m pytest tests/ -v
Expected: 61 passed.