- Version: 1.3.0
- Runtime: Python 3.10+ (FastAPI)
- Architecture: Clean Architecture (Domain → Service → Adapter → API)
HydraLLM is a locally-run orchestration gateway that unifies heterogeneous LLM resources — cloud free/low-cost tiers (Gemini, Groq, Cerebras) and local agents (Ollama, OpenCode, OpenClaw) — behind a single OpenAI-compatible endpoint (POST /v1/chat/completions).
"Local" describes where the gateway runs and where it can fully fall back to, not where the models live. Any OpenAI-compatible client (Claude Code, Cursor, OpenClaw, Continue) connects by changing only its base_url.
- Research / educational framing (primary intent). HydraLLM is an open research project that demonstrates techniques — API orchestration, dynamic resource management / load optimization, context-aware routing across heterogeneous providers, and real-time context enrichment. It is not a production-SLA product, and responsibility for each provider's Terms of Service and rate limits rests entirely with the user (see
DISCLAIMER.md). - Practical behaviour (the form that demonstration takes). A single highly-available endpoint that lets clients use a pool of free/low-cost keys as if it were one premium endpoint, degrading gracefully to local models when cloud resources are exhausted.
- Not a production-SLA / billing / cost-tracking platform.
- Not a fine-tuning or model-training system.
- Not distributed — single-node only (Redis-backed key/breaker sync is roadmap, see
IMPROVEMENTS.md).
The codebase shape follows from these principles (each is a target; where the current implementation diverges it is tracked as a defect, not a redefinition of intent):
- One socket. Every provider hides behind the OpenAI schema; drop-in
base_urlswap. - Availability first, graceful degradation. CircuitBreaker →
PROVIDER_PRIORITYfallback → final local fallback (Ollama). Never hard-fail while any resource remains. - Context-aware right-sizing. Route to the cheapest/fastest adequate model for the request context, not always the most capable one.
- Local sovereignty & self-sufficiency. Runs locally; can degrade entirely to local agents with no hard cloud dependency.
- Self-healing & autonomy. Background key re-probing (honouring cooldowns), dynamic model discovery, web-keyword learning, scraper auto-restart.
- Continuity across transitions. Session context survives provider/agent/model switches via SQLite (WAL).
- Real-time grounding. Web enrichment overcomes training-cutoff staleness; the "Web context injected" log makes application observable.
- Strict boundaries. Unidirectional Clean Architecture, thin orchestrator + collaborators, async-first.
- Observability & transparency.
routing_reasonconstants, comm logs, metrics, unified/uidashboard. - Research framing, user-borne compliance. Explicitly experimental; multi-key rotation's ToS sensitivity is disclosed.
src/
├── app.py # FastAPI App Factory + Lifespan (Discovery, Probing, Recovery)
├── core/
│ ├── config.py # Pydantic Settings — Centralized environment management
│ ├── exceptions.py # Custom Exceptions (ResourceExhaustedError, RateLimitError, etc.)
│ └── logging.py # Configuration for local and console logging
├── domain/ # Pure, framework-free (nested `logic.py` modules)
│ ├── enums/logic.py # ProviderType, AgentType, ModelType, TierType, RoutingReason, PartType
│ ├── models.py # ChatRequest, ChatResponse, ChatMessage, RoutingDecision (Pydantic v2)
│ ├── schemas/logic.py # API Response DTOs (ModelInfo, ModelListResponse, ProviderStatus)
│ └── interfaces/logic.py # ABCs: ILLMProvider, IContextAnalyzer, IKeyManager, IRouter, ISessionManager, IContextManager
├── services/ # Gateway is a thin orchestrator; heavy logic lives in collaborators
│ ├── gateway.py # Orchestration, URL auto-detection, retry-loop wiring
│ ├── resilience_manager.py # Per-request retry loop, circuit-breaker checks, final_fallback→Ollama
│ ├── agent_executor.py # Local-agent (Ollama/OpenCode/OpenClaw) execution + chat-model resolution; injected into ResilienceManager
│ ├── web_coordinator.py # Web enrichment call, "Web context injected" log, messages.insert(-1)
│ ├── session_orchestrator.py # Session load/persist + compression handoff (exposed as gateway.sessions)
│ ├── analyzer.py # Context analysis → Routing decision
│ ├── key_manager.py # Key pools, random rotation, category-based cooldowns, probe recovery
│ ├── circuit_breaker.py # Per-provider breaker (5 failures → open 60s)
│ ├── scraper.py # Playwright-based web scraping (URL fetch, search, SSRF protection)
│ ├── web_context_service.py # Web enrichment orchestration + scraping_metrics
│ ├── intent_classifier.py # Web-intent classification (substring → embedding fallback)
│ ├── keyword_store.py # Per-language web keyword persistence (data/web_keywords.{ko,en}.json)
│ ├── context_manager.py # Multimodal context offload (used by Gemini adapter)
│ ├── compressor.py # LLMLingua-2 based prompt/session compression
│ ├── session_manager.py # SQLite WAL-based persistent session storage + settings (compaction delegated)
│ ├── memory_optimizer.py # Compaction V2 (selective pruning + structured summary + dedup); SessionManager.compact delegates here
│ ├── admin_service.py # Admin operations (onboarding, model refresh, settings)
│ ├── installer.py # Local CLI agent installation (opencode/openclaw)
│ ├── comm_logger.py # Provider request/response debug buffer (/admin/comm-logs)
│ ├── metrics_service.py # Runtime metrics
│ └── observability.py # Step-level observability (record_step)
├── adapters/
│ └── providers/
│ ├── gemini.py # Google GenAI (format conversion, multimodal)
│ ├── openai_compat.py # Groq + Ollama/local OpenAI-compatible endpoints (base_url override)
│ ├── cerebras.py # Dedicated Cerebras adapter (CerebrasAdapter)
│ └── local_cli.py # OpenCode, OpenClaw (subprocess-based integration)
├── api/
│ └── v1/
│ ├── endpoints.py # Route definitions (chat, models, admin)
│ └── dependencies.py # FastAPI Dependency Injection (services from app.state)
├── i18n/ # Locale-based message translation (t(), set_locale)
└── utils/
└── ulid.py # stdlib-only ULID / session-id generation
tests/
├── conftest.py # Project root discovery and sys.path setup
├── api/ # API endpoint tests
├── unit/ # Component logic tests
└── integration/ # Full request flow tests
Gateway.process_request orchestrates the steps below in this exact order. Note
that routing is decided before web enrichment — the routing token count reflects the
user query + session history, not the (potentially large) injected web reference data.
Client ──► POST /v1/chat/completions (or /v1/responses)
│
▼ api/v1/endpoints.py (admin routes gated by verify_admin_auth)
▼ Gateway.process_request
│
1. Normalize input prompt → messages; reject empty
2. SessionOrchestrator load_history() + save_user_message() ── SQLite WAL
3. Compose prompt prepend [SYSTEM CONTEXT] date/truth msg; merge+dedup history
4. _get_available_tiers() per provider: CircuitBreaker.is_available() → tier set
(OPEN breaker ⇒ empty set ⇒ provider excluded from routing)
5. Analyzer.analyze() ──► RoutingDecision(provider|agent, model_name, reason)
[token count here is PRE-enrichment]
6. WebEnrichmentCoordinator.enrich()
if web intent/URL: scrape → messages.insert(-1, web block)
emits "Web context injected: N chars ..." (canonical log)
7. ResilienceManager.execute_with_resilience() ── retry loop, see §6
├─ CircuitBreaker.is_available(provider)? no → skip (strict ⇒ raise)
├─ KeyManager.get_next_key(provider) random from active pool
├─ Adapter.generate() Gemini / OpenAI-compat / Cerebras / Local CLI
├─ success → CircuitBreaker.report_success + KeyManager.report_success → break
└─ failure → CircuitBreaker.report_failure + KeyManager.report_failure(error)
├─ retry (max 3) within provider
├─ keys exhausted → fall through PROVIDER_PRIORITY chain
└─ all providers exhausted → final_fallback → Ollama (local)
8. Persist response to session; return (streaming: chat.completion.chunk + [DONE])
ProviderType: gemini, groq, cerebras.AgentType: ollama, opencode, openclaw.ModelType: discovered model identifiers per provider (do not hardcode; extended via discovery).RoutingReason: token_count, image_present, model_hint, key_availability, rate_limit.PartType: multimodal message part types (text, image, …).
Web-search intent is carried by
RoutingStrategy.SEARCH_REQUIRED, not byRoutingReason.
Tiers are managed via
TierTypeEnum:FREE,STANDARD,PREMIUM,EXPERIMENTAL,UNKNOWN.
ChatRequest: Extends OpenAI standard withsession_id,has_search,web_fetch,compress_context.ChatResponse: OpenAI compatible structure.RoutingDecision: Internal model for analyzer results.
ProviderStatus, AgentStatus, ModelCapabilities, ModelInfo, ModelListResponse.
Seven abstract base classes. Adapters/services implement these; inner layers depend on the ABCs, never on concrete outer classes.
ILLMProvider:generate,get_supported_models,get_max_tokens,is_multimodal,discover_models,probe_key.IContextAnalyzer:analyze,get_supported_models_info,get_all_discovered_models_info,register_model.IKeyManager:get_next_key(provider, min_tier),report_success,report_failure(provider, key, error),get_key_status.IRouter:route_request,get_status,get_supported_models,get_all_models.ISessionManager: session load/persist, settings, provider-health, and web-cache/scraping-metrics accessors (SQLite-backed).IContextManager: multimodal context offload/caching (should_offload,prepare_temp_file,get_cached_file,cache_file, …).IWebScraper:startup,shutdown,scrape_url(url, mode, timeout_ms),search_and_scrape(query, num_results, mode)— the web backend contract, implemented byservices/scraper.py::WebScraper(inprocess) andadapters/web/mcp_search.py::McpWebScraper(mcp).
The Gemini adapter depends on the
IContextManagerABC;services.context_manager.ContextManageris the concrete implementation. All adapters depend only on domain ABCs, never on concrete service classes (unidirectional dependency is fully observed).
All routes are mounted under /v1 via api/v1/endpoints.py. There are 2 public routes
and 25 admin routes; every admin route depends on verify_admin_auth (see §4.3).
POST /v1/chat/completions — OpenAI-compatible chat. ChatRequest extends the OpenAI
body with: session_id (local persistence), has_search / auto_web_fetch / web_fetch
(web enrichment controls), compress_context (LLMLingua compaction), prompt (legacy
single-string input), stream. model="auto" (or PROVIDER/auto) triggers routing (§5).
When stream=true, the response is emitted as chat.completion.chunk SSE events
terminated by data: [DONE].
GET /v1/models — dynamically discovered model catalogue (ModelListResponse); includes
per-model multimodal / has_search capabilities and virtual PROVIDER/auto entries.
Not implemented: a
POST /v1/responsesOpenClaw alias (input→messages, immediateresponse.created) was described in earlier drafts but does not exist inendpoints.pyand is out of current scope. It is recorded here only so the spec is not mistaken for claiming it; implement it as a future feature if OpenClawopenai-responsesmode is needed.
Grouped by concern (all under /v1/admin):
- Observability:
GET stats,GET dashboard,GET status,GET logs,POST logs/clear,GET comm-logs,DELETE comm-logs. - Models/keys:
POST refresh-models,POST probe,POST keys. - Sessions:
GET sessions,POST sessions/new,DELETE sessions/{id},POST sessions/cleanup,GET sessions/{id}/messages,POST sessions/import. - Settings/onboarding:
GET/PUT settings,GET/POST onboarding. - Install (local agents):
GET install/status,POST install/{tool}(runscurl … | bashfor opencode/openclaw — keep behind auth, see §10). - Web-intent keywords:
GET keywords,POST keywords({lang, keywords[]}),POST keywords/learn({query}) — routed viaget_keyword_store/get_intent_classifier.
- Admin auth:
X-Admin-Keyheader checked againstADMIN_API_KEY. If the key is unset, access is fail-closed (503) unless running with--debug/DEBUG=true, in which case admin is open for local development (host binds0.0.0.0, so open-by-default is unsafe). - Error contract: failures surface as HTTP error responses; internal exception detail MUST be masked in client-facing 5xx responses (full detail goes to server logs only) to avoid leaking provider endpoints / SDK internals.
Analyzer.analyze returns a RoutingDecision by evaluating the rules below as an ordered
decision procedure — first match wins. The token count used is measured on the user
query + session history (pre-web-enrichment, see §2.2); injected web data does not
change the routing tier.
1. Explicit provider/model hint
├─ "provider/model" or a recognized model id → that provider; strict (no cross-provider fallback)
└─ recognized cross-vendor alias (e.g. "gpt-4o") → mapped to a pre-defined equivalent
2. Provider-scoped auto ("GEMINI/auto", "GROQ/auto", "CEREBRAS/auto")
→ stay within that provider; pick a model by its current key tiers
3. Multimodal (images present)
→ Gemini Vision (if no Gemini key is available → falls through to step 5 token routing)
4. Web intent (has_search / detected real-time query)
→ influences **enrichment** (web context is scraped and injected, ≤6000 chars) but does
**NOT** force a provider. The enriched request routes by token size like any other →
usually the fast tier (de-funnels Gemini; the injected context fits the fast window).
5. Token count (single canonical threshold = settings.max_tokens_fast_model, default 8192)
├─ tokens < threshold → FAST TIER: Groq ↔ Cerebras, **round-robin** across available
│ fast-tier providers (load distribution, not Groq-always)
└─ tokens >= threshold → Gemini (large context window)
6. Tier awareness (applied within the above)
→ premium/Pro models are selected ONLY when PREMIUM-tier keys exist in the pool;
otherwise the free/standard model for that provider is used.
Invariants (enforced; violations are defects):
- The threshold is
settings.max_tokens_fast_modeleverywhere — both theautopromotion path and_determine_strategy. No separate hardcoded cut-off (e.g. 7000). - Provider-keyword precedence: when a model id contains a provider keyword
(
cerebras,groq), that keyword wins over generic family substrings (llama,deepseek);ollama(which contains the substringllama) is matched first of all. - A provider whose CircuitBreaker is OPEN, or whose key pool is empty, is excluded from candidate selection (its tier set is empty in step 5/6).
TierType values: FREE, STANDARD, PREMIUM, EXPERIMENTAL, UNKNOWN.
The Analyzer returns the context-ideal provider and never selects a local agent on its
own. It is availability-aware only in the fast / None-preferred path (small query with no
fast-tier key falls to an available provider, e.g. Gemini); with no keys at all it still
returns a cloud default (small → Groq, large → Gemini). A large/auto request always
prefers Gemini (large window) even if Gemini has no key. The real key-absence handling —
provider fallback and degradation to local — happens in ResilienceManager (§6.4), not here.
Owned by ResilienceManager.execute_with_resilience (the retry loop) and per-provider
CircuitBreaker instances. New retry/fallback logic belongs here, not in Gateway.
Agent execution (local-agent chat-model resolution + adapter call, used by the agent path and
final_fallback) is owned byAgentExecutor(services/agent_executor.py).Gatewayconstructs it and injectsagent_executor.processintoResilienceManagerasprocess_with_agent; ResilienceManager only calls that callable.
Strictness is decided once, by the Analyzer, from the original hint and carried on
RoutingDecision.strict — ResilienceManager consumes decision.strict and never
re-parses request.model (which Gateway overwrites with decision.model_name before
execution). A request is strict (no cross-provider fallback) when the hint is explicit:
contains / and names a provider/agent (gemini/…, GEMINI/auto, local-agent/…).
Strict requests raise on failure of the named target. All other requests (auto, aliases
like gpt-4o) are fallback requests and traverse the provider chain.
Design note (decision vs. recovery, intentional): the Analyzer (pure, deterministic, I/O-free) decides the primary target;
ResilienceManagerowns recovery (retry, circuit checks, provider fallback, local final-fallback) at execution time — because key-exhaustion/circuit-open/provider-5xx are only known while running. This split is a feature, not redundant routing; do not collapse the two layers.
agent path (if decision.agent set):
try the local agent first; on failure (non-strict) → log + continue to cloud chain
for provider in PROVIDER_PRIORITY (gemini, groq, cerebras, …):
if not CircuitBreaker[provider].is_available(): # OPEN
strict → raise ResourceExhaustedError ; else skip provider
for attempt in range(max_retries = 3):
key = KeyManager.get_next_key(provider)
resp = Adapter.generate(request, key)
success → CircuitBreaker.report_success + KeyManager.report_success → return
on RateLimitError / ResourceExhaustedError:
CircuitBreaker.report_failure + KeyManager.report_failure(error)
strict → raise ; if active keys == 0 → break to next provider ; else retry
on ServiceUnavailableError: same handling (short backoff)
on other Exception: report_failure to KeyManager only if 403/auth-like; strict → raise
all providers exhausted → final_fallback():
decision.agent = OLLAMA ; model = "" → AgentExecutor.process (local, never circuit-broken)
Single-threaded asyncio; methods are synchronous and therefore atomic (no lock needed).
| State | Behaviour |
|---|---|
| CLOSED | requests pass; failure_count resets on success |
| → OPEN | after failure_threshold (5) consecutive failures; blocks for recovery_timeout (60s) |
| OPEN → HALF_OPEN | after the timeout elapses, admits exactly one probe (thundering-herd guard) |
| HALF_OPEN + success | → CLOSED, counters reset |
| HALF_OPEN + failure | → OPEN immediately (does not wait for threshold) |
| HALF_OPEN stuck probe | a new probe is admitted after recovery_timeout (self-heal) |
How missing keys actually route (resolved here, not in the Analyzer — see §5.1):
| Situation | Behaviour |
|---|---|
| Some providers lack keys | The decided provider's get_next_key fails → fall through the PROVIDER_PRIORITY chain to a provider that has keys. |
auto + no cloud keys at all |
Cloud attempts are skipped entirely; request goes immediately to local Ollama via final_fallback (resilience_manager.py early-exit). |
| All cloud providers exhausted mid-run | final_fallback → Ollama (local, never circuit-broken). |
strict request (explicit provider/model) |
No fallback. If the named provider has no key it raises ResourceExhaustedError; it does not degrade to local. |
Net effect: an auto request never hard-fails while any resource (including local Ollama)
remains; a strict request honours the user's explicit choice and fails fast instead.
- Random Rotation: Uniform distribution of requests across the (eligible) key pool for balanced utilization.
- Quota tagging: On quota/billing errors a key is tagged in metadata (
is_quota_limit=True) and moved to the failed pool with a 1h cooldown. Tier values are assigned at probe time (probe_key), not downgraded on failure. - Category-based Cooldowns: On failure a key is moved to the failed pool with a
cooldown_untilderived from the error category:403 Forbidden: 24 hours (persistent per-key fault).429 Rate Limit / Quota: 1 hour.- Other transient errors: 5 minutes.
- Self-Healing: A background task polls every 60 seconds, but only re-probes keys whose
cooldown_untilhas elapsed. The 60s interval is the polling cadence, not an override of the category cooldowns — a key in cooldown is skipped until its window expires. (Probing a 403-cooldown key every 60s would defeat the 24h backoff.) - Concurrency: All mutations of the active/failed pools (
get_next_key,report_success,report_failure, recovery) are guarded by the sameasyncio.Lockto preserve pool integrity under load.
Note: Multi-key rotation may be restricted by some providers' Terms of Service. Users are responsible for reviewing and complying with their provider's policies.
Grounds responses in real-time web data. Owned by WebEnrichmentCoordinator
(web_coordinator.py) over WebContextService (web_context_service.py),
IntentClassifier, KeywordStore, and an IWebScraper backend. Runs after routing (§2.2).
WebContextService.enrich_request(request):
auto_web_fetch = request.auto_web_fetch ?? settings.enable_auto_web_fetch.- Extract explicit URLs from the query via
_URL_PATTERN(https?://…); appendrequest.web_fetchif set. - If explicit URLs → scrape each. Otherwise, if
IntentClassifier.needs_web_search(query)orrequest.has_searchand no blocks yet →search_and_scrape. - Per-fetch cap
max_chars = 6000. Results cached in SQLite forsettings.web_cache_ttl_hours; every attempt is logged toscraping_metricsviarecord_scraping(url, status, chars, latency)(status∈ cache_hit/success/failed/error). - Extraction: content is extracted to clean markdown via
trafilaturawhen installed (denser signal per token, so the fast tier answers web queries well); falls back to BeautifulSoup text extraction whentrafilaturais absent (no regression). Combined with step 4's cap, the injected web context comfortably fits the fast-tier window — which is why web intent no longer forces Gemini (§5 step 4).
IntentClassifier.needs_web_search evaluates in order: substring match against the
keyword store → regex patterns (§8.3a) → trivial/meta-query early-return (False) → embedding similarity
(cosine vs positive/negative exemplars, model bge-m3:latest; the operative threshold is
0.58, set in i18n/{en,ko}.json and loaded by initialize() — the 0.65 hardcoded in
IntentClassifier.__init__ is always overridden. Positive iff max_pos ≥ threshold and avg_pos > avg_neg).
If embeddings are not ready (is_ready=False) only the embedding step is skipped; keyword and
regex checks still run.
KeywordStore persists per-language lists to data/web_keywords.{ko,en}.json: entries are
length 2–60, case-insensitively de-duplicated, FIFO-capped at 200/lang. Keywords are added
only through the admin routes (§4.2); scripts/validate_flow.py auto-feeds false
negatives to …/keywords/learn. Do not hardcode keywords in source.
data/web_patterns.{ko,en}.json hold include/exclude lists of {id, desc, pattern}. Both
languages' patterns apply; a query is web-positive when an include matches and no exclude
does (reported as regex:<lang>:<id>). Includes catch explicit search requests
("검색해서", "search for", "look it up"); excludes drop sentences where search is the topic
("검색 기능", "binary search", "search engine"). Invalid regexes are logged and skipped.
- Engine chain:
SEARCH_PROVIDERS(defaultduckduckgo,bing) is tried in order; zero links or an error falls through to the next engine.openserp(OPENSERP_*) andsearxng(SEARXNG_*, JSON format) are optional. - Backend:
WEB_BACKEND=inprocess(default) uses the in-gatewayWebScraper.WEB_BACKEND=mcpusesMcpWebScraper, a minimal httpx Streamable HTTP MCP client, calling the standalonesearch_mcp/server.py(own venv: the MCP SDK needsanyio>=4, FastAPI 0.104 pinsanyio<4). On any MCP failure it falls back to the in-process scraper whenSEARCH_MCP_FALLBACK=true(browser started lazily on first fallback). No extra LLM calls: the gateway still decides whether to search.SEARCH_MCP_TOKENenables Bearer auth. Seesearch_mcp/README.ko.md.
When web_text is produced, the coordinator inserts a system message
(name="web_context", wrapped in --- REAL-TIME WEB CONTEXT START/END ---) at
messages.insert(-1, …) and emits the canonical stdout log
Web context injected: N chars into request.messages[-2] (session=…). This log is the
authoritative signal that enrichment was applied; tests assert it and it MUST NOT regress.
Owned by SessionManager (SQLite) and SessionOrchestrator (gateway.sessions).
- Connections:
_get_conn()opens a fresh connection per call withPRAGMA journal_mode=WAL. Per-task connections (not a shared singleton) preventdatabase is lockedunder concurrent reads/writes. - Schema:
sessions(withfork_point_message_idfor branching),messages, andparts(type∈text,web_fetch,web_search,compaction,step_cost,retry). - Compaction boundary:
load_context/load_historyreturn only the messages after the most recentcompactionpart (loadingtext/compactionparts past that point). This truncates long histories while preserving the compacted summary as the new base. - MemoryOptimizer (
services/memory_optimizer.py): compaction is owned byMemoryOptimizer;SessionManager.compactdelegates to it (injecting a connection factory + token estimator). Compaction V2 runs while oversession_compact_threshold: Phase A selective pruning (oldweb_fetch→[PRUNED], deleteretry, protect the recentsession_recent_window) → Phase B structured summary viaContextCompressor(LLMLingua-2), whose input is de-duplicated by(role, text[:500])to cut redundant cost → writes thecompactionmarker. - Continuity: because context is keyed by
session_idand not by provider, a session's history survives provider/agent/model switches (regression-guarded bytest_gateway_model_switch_continuity.py). - cp949 safety:
_get_project_idruns git via subprocess withencoding="utf-8".
- Async-first: every I/O path (HTTP, DB, subprocess) is
async.time.sleep,requests, and other blocking calls are prohibited in services/adapters (asyncio.sleep,httpx.AsyncClient,asyncio.subprocessinstead). Single-node asyncio. - Security model:
- SSRF:
WebScraperresolves the host and blocks any internal/reserved IP via property check (is_private/loopback/link_local/reserved/multicast/unspecified+ IPv4-mapped normalization + CGNAT). Redirect re-validation (best-effort): the final URL after redirects is re-checked on both fetch paths (StealthyFetcher.response.url, Playwrightpage.url), and the Playwright path additionally aborts document/navigation requests that resolve to internal addresses via acontext.routeguard. Residual: DNS-rebinding within a single resolution window, and StealthyFetcher's pre-flight request before final-URL re-check, remain — full mitigation needs IP-pinning, which is infeasible with the browser-based fetchers in use. - Admin auth:
X-Admin-KeyvsADMIN_API_KEY; fail-closed (503) when the key is unset unless--debug/DEBUG=true. - Secret handling: API keys are never logged or returned in full (truncated to
[:8]…); client-facing 5xx responses must not leak internal exception detail.
- SSRF:
- Encoding: UTF-8 is forced on log handlers and
sys.stdout(core/logging.py) for Korean-Windows (cp949) safety. - Terms of Service: multi-key rotation may be restricted by some providers' ToS;
compliance is the user's responsibility (
DISCLAIMER.md). The ToS notice must not be removed without user consent.
This project is licensed under the MIT License.