dakshtaneja's picture
citations: space before the link, and a hover preview card
4ec29a9
Raw
History Blame Contribute Delete
13 kB
"""Thin async OpenRouter client with JSON-mode helpers."""
import asyncio
import json
import re
import time
from typing import Any
from urllib.parse import urlparse
import httpx
from .config import ModelSpec, settings
class LLMError(Exception):
pass
def _citation_items(annotations: list | None) -> list[dict]:
"""Pull {url, title} out of OpenRouter's url_citation annotations.
The title is what the hover preview shows, so it's carried alongside the
URL rather than thrown away with the rest of the annotation.
"""
out: list[dict] = []
seen: set[str] = set()
for a in annotations or []:
if not isinstance(a, dict):
continue
cite = a.get("url_citation") or {}
url = cite.get("url") or ""
if not url.startswith("http") or url in seen:
continue
seen.add(url)
out.append({"url": url, "title": str(cite.get("title") or "")[:160]})
return out
def _citation_urls(annotations: list | None) -> list[str]:
return [c["url"] for c in _citation_items(annotations)]
# The web plugin writes citations as a bare domain in fullwidth brackets —
# 【pmindia.gov.in】 — which renders as text that looks like a link but isn't.
# The real URLs come back in message.annotations, so the two can be matched up.
_CITE_MARKER = re.compile(r"【\s*([^【】\s]+?)\s*】")
def link_citations(text: str, citations: list) -> str:
"""Turn 【domain】 markers into real markdown links.
Matches each marker to the annotation whose host it belongs to, so the
link lands on the actual cited page rather than the site's front door.
Markers with no matching annotation are left exactly as they are — a
plain-domain link would be a guess, and a wrong link is worse than none.
The annotation title rides along as the link's markdown title, which is
what the frontend's hover preview reads. Accepts either {url, title}
dicts or bare URL strings.
"""
if not text or not citations:
return text
hosts = []
for c in citations:
item = {"url": c, "title": ""} if isinstance(c, str) else c
try:
host = urlparse(item["url"]).netloc.lower()
except (ValueError, KeyError, TypeError):
continue
hosts.append((host.removeprefix("www."), item))
def repl(m: re.Match) -> str:
label = m.group(1)
# removeprefix, not lstrip: lstrip strips any of "w"/"." from the
# front, turning "wikipedia.org" into "ikipedia.org".
key = label.lower().removeprefix("www.")
matches = [item for host, item in hosts
if host == key or host.endswith("." + key)
or key.endswith(host)]
if not matches:
return m.group(0)
# Several citations can share a host; prefer one that carries a title,
# since that's what the hover preview has to show.
item = next((i for i in matches if i.get("title")), matches[0])
title = (item.get("title") or "").replace('"', "'")
suffix = f' "{title}"' if title else ""
# The model glues the marker straight onto the preceding word
# ("Self-Defence Forcesen.wikipedia.org"), so give the link room
# unless it already follows whitespace or an opening bracket.
start = m.start()
lead = "" if start == 0 or text[start - 1] in " \t\n([" else " "
return f"{lead}[{label}]({item['url']}{suffix})"
return _CITE_MARKER.sub(repl, text)
class LLMResponse:
def __init__(self, content: str, tokens_in: int, tokens_out: int,
latency_ms: int, served_model: str = ""):
self.content = content
self.tokens_in = tokens_in
self.tokens_out = tokens_out
self.latency_ms = latency_ms
self.served_model = served_model # which model actually answered
_client: httpx.AsyncClient | None = None
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(
base_url=settings.openrouter_base_url,
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"HTTP-Referer": "https://github.com/auctionrouter",
"X-Title": "AuctionRouter",
},
timeout=settings.request_timeout_s,
)
return _client
async def close_client() -> None:
global _client
if _client is not None:
await _client.aclose()
_client = None
def _build_messages(system: str, user: str,
history: list[dict] | None) -> list[dict]:
# Ground every model in the current date so it doesn't treat recent
# events as "hasn't happened yet" (and flags needs_web correctly)
from datetime import datetime, timezone
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
return [
{"role": "system", "content": f"Today's date is {today}.\n\n{system}"},
*({"role": t["role"], "content": t["content"]} for t in (history or [])),
{"role": "user", "content": user},
]
def _request_body(model: ModelSpec, system: str, user: str,
max_tokens: int | None,
reasoning_effort: str | None,
history: list[dict] | None,
prefer_paid: bool,
web: bool = False) -> dict:
# Latency-critical calls (bids, drafts) skip the free pool: paid
# endpoints respond in a fraction of the time
model_id = model.fallback_id if prefer_paid and model.fallback_id \
else model.openrouter_id
body: dict = {
"model": model_id,
"messages": _build_messages(system, user, history),
"max_tokens": max_tokens or settings.max_answer_tokens,
}
if reasoning_effort:
body["reasoning"] = {"effort": reasoning_effort}
if model.fallback_id and not prefer_paid:
# OpenRouter fallback routing: try free primary, then paid fallback
body["models"] = [model.openrouter_id, model.fallback_id]
if settings.openrouter_provider_sort:
# Route to the fastest provider for the model rather than the
# default (cheapest) — big variance cut for multi-provider models
body["provider"] = {"sort": settings.openrouter_provider_sort}
if web and settings.web_search_enabled:
# OpenRouter web plugin: runs a search and injects results into
# context before the model answers ($0.004/search)
body["plugins"] = [{"id": "web",
"max_results": settings.web_search_max_results}]
return body
async def chat(model: ModelSpec, system: str, user: str,
timeout: float | None = None,
max_tokens: int | None = None,
reasoning_effort: str | None = None,
history: list[dict] | None = None,
prefer_paid: bool = False,
web: bool = False) -> LLMResponse:
start = time.monotonic()
body = _request_body(model, system, user, max_tokens,
reasoning_effort, history, prefer_paid, web)
# Free-tier models often 429 transiently ("rate-limited upstream,
# retry shortly"), so retry a couple of times honoring Retry-After.
attempts = 3
for attempt in range(attempts):
resp = await get_client().post(
"/chat/completions",
json=body,
timeout=timeout or settings.request_timeout_s,
)
if resp.status_code != 429 or attempt == attempts - 1:
break
retry_after = min(float(resp.headers.get("Retry-After", 2)), 2.0)
await asyncio.sleep(retry_after)
latency_ms = int((time.monotonic() - start) * 1000)
if resp.status_code != 200:
raise LLMError(f"{model.openrouter_id}: HTTP {resp.status_code}: {resp.text[:300]}")
data = resp.json()
if "error" in data: # OpenRouter can embed provider errors in a 200
raise LLMError(f"{model.openrouter_id}: {str(data['error'])[:300]}")
try:
message = data["choices"][0]["message"]
content = message["content"] or ""
except (KeyError, IndexError) as e:
raise LLMError(f"{model.openrouter_id}: malformed response: {e}")
content = link_citations(content, _citation_items(message.get("annotations")))
usage = data.get("usage") or {}
return LLMResponse(
content=content,
tokens_in=usage.get("prompt_tokens", 0),
tokens_out=usage.get("completion_tokens", 0),
latency_ms=latency_ms,
served_model=data.get("model", model.openrouter_id),
)
async def chat_stream(model: ModelSpec, system: str, user: str,
timeout: float | None = None,
max_tokens: int | None = None,
reasoning_effort: str | None = None,
history: list[dict] | None = None,
prefer_paid: bool = False,
web: bool = False):
"""Streaming variant of chat().
Yields {"type": "delta", "text": ...} per token chunk, then a final
{"type": "final", "response": LLMResponse} with full content and usage.
"""
body = _request_body(model, system, user, max_tokens,
reasoning_effort, history, prefer_paid, web)
body["stream"] = True
body["stream_options"] = {"include_usage": True}
start = time.monotonic()
parts: list[str] = []
citations: list[dict] = []
tokens_in = tokens_out = 0
served = model.openrouter_id
attempts = 3
for attempt in range(attempts):
async with get_client().stream(
"POST", "/chat/completions", json=body,
timeout=timeout or settings.request_timeout_s,
) as resp:
if resp.status_code == 429 and attempt < attempts - 1:
retry_after = min(float(resp.headers.get("Retry-After", 2)), 2.0)
await asyncio.sleep(retry_after)
continue
if resp.status_code != 200:
text = (await resp.aread()).decode(errors="replace")
raise LLMError(f"{model.openrouter_id}: HTTP {resp.status_code}: {text[:300]}")
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = line[len("data: "):].strip()
if payload == "[DONE]":
break
try:
data = json.loads(payload)
except json.JSONDecodeError:
continue
if "error" in data:
raise LLMError(f"{model.openrouter_id}: {str(data['error'])[:300]}")
served = data.get("model", served)
usage = data.get("usage")
if usage:
tokens_in = usage.get("prompt_tokens", tokens_in)
tokens_out = usage.get("completion_tokens", tokens_out)
choices = data.get("choices") or []
if choices:
delta = choices[0].get("delta") or {}
# Citations arrive alongside the deltas (or on the final
# message); collected here so the completed answer can be
# rewritten with real links below.
for src in (delta, choices[0].get("message") or {}):
for c in _citation_items(src.get("annotations")):
if all(c["url"] != e["url"] for e in citations):
citations.append(c)
thinking = delta.get("reasoning") or ""
if thinking:
# Reasoning summaries stream before content on
# reasoning models; provider support varies
yield {"type": "reasoning_delta", "text": thinking}
piece = delta.get("content") or ""
if piece:
parts.append(piece)
yield {"type": "delta", "text": piece}
break
yield {
"type": "final",
"response": LLMResponse(
# Deltas streamed raw; the finished answer gets real links. The
# client replaces the streamed text with this on `done`.
content=link_citations("".join(parts), citations),
tokens_in=tokens_in,
tokens_out=tokens_out,
latency_ms=int((time.monotonic() - start) * 1000),
served_model=served,
),
}
def extract_json(text: str) -> dict[str, Any]:
"""Pull the first JSON object out of a model response.
Handles raw JSON, ```json fences, and JSON embedded in prose.
"""
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if fenced:
text = fenced.group(1)
else:
brace = re.search(r"\{.*\}", text, re.DOTALL)
if brace:
text = brace.group(0)
return json.loads(text)