Add Stack Overflow Omi integration app#7414
Conversation
Greptile SummaryThis PR adds a standalone Omi plugin that proxies the public Stack Exchange API, exposing three no-auth chat tools (
Confidence Score: 3/5The app is straightforward with no secrets or auth surface, but two logic bugs in main.py produce incorrect behavior on the primary code paths. The missing backoff check means the service can be throttled or blocked by Stack Exchange under normal usage, silently degrading all three tools. The _question_url bug produces broken links for non-StackOverflow SE sites on every get_top_answers call. Both issues affect the main code path and are visible to end users immediately. plugins/omi-stack-overflow-app/main.py — specifically _request_json (backoff handling) and _question_url (domain mapping) Important Files Changed
Sequence DiagramsequenceDiagram
participant Omi as Omi Platform
participant App as omi-stack-overflow-app (FastAPI)
participant SE as Stack Exchange API
Omi->>App: GET /.well-known/omi-tools.json
App-->>Omi: tool manifest (3 tools)
Omi->>App: POST /tools/search_questions
App->>App: _safe_site() / _safe_tags() / _safe_limit()
App->>SE: "GET /search/advanced?site=...&q=..."
SE-->>App: "{items: [...], backoff?: N}"
Note over App: backoff field currently ignored
App->>App: _format_question() x N
App-->>Omi: "{result: formatted list}"
Omi->>App: POST /tools/get_question
App->>SE: "GET /questions/{id}?filter=withbody"
SE-->>App: "{items: [...]}"
App->>App: _clean_text(body)
App-->>Omi: "{result: title + metadata + excerpt}"
Omi->>App: POST /tools/get_top_answers
App->>SE: "GET /questions/{id}/answers?sort=votes"
SE-->>App: "{items: [...]}"
App->>App: _format_answer() x N
Note over App: URL via _question_url() wrong for serverfault/superuser/etc
App-->>Omi: "{result: URL + ranked answers}"
Reviews (1): Last reviewed commit: "Add Stack Overflow Omi integration app" | Re-trigger Greptile |
| response.raise_for_status() | ||
| data = response.json() | ||
| if data.get("error_id"): | ||
| raise ValueError(data.get("error_message") or "Stack Exchange API returned an error") | ||
| return data |
There was a problem hiding this comment.
The Stack Exchange API's
backoff field is not checked. When this field is present in a response, the API's terms require the client to wait that many seconds before making another request to the same endpoint. Ignoring it can cause Stack Exchange to begin returning 429s or temporarily blocking the service's IP entirely, breaking all three tool endpoints for every user.
| response.raise_for_status() | |
| data = response.json() | |
| if data.get("error_id"): | |
| raise ValueError(data.get("error_message") or "Stack Exchange API returned an error") | |
| return data | |
| response.raise_for_status() | |
| data = response.json() | |
| if data.get("error_id"): | |
| raise ValueError(data.get("error_message") or "Stack Exchange API returned an error") | |
| if data.get("backoff"): | |
| raise ValueError( | |
| f"Stack Exchange API requested a backoff of {data['backoff']} seconds. " | |
| "Retry the request after that delay." | |
| ) | |
| return data |
| def _question_url(site: str, question_id: Any) -> str: | ||
| host = "stackoverflow.com" if site == DEFAULT_SITE else f"{site}.stackexchange.com" | ||
| return f"https://{host}/questions/{question_id}" |
There was a problem hiding this comment.
_question_url unconditionally constructs {site}.stackexchange.com for every site except stackoverflow. Several major Stack Exchange network sites have their own top-level domains (serverfault.com, superuser.com, askubuntu.com, meta.stackoverflow.com, meta.stackexchange.com). In get_top_answers this URL is always emitted in the result (line 378), so users querying those sites will receive broken links pointing to a non-existent host like serverfault.stackexchange.com.
| def _question_url(site: str, question_id: Any) -> str: | |
| host = "stackoverflow.com" if site == DEFAULT_SITE else f"{site}.stackexchange.com" | |
| return f"https://{host}/questions/{question_id}" | |
| _KNOWN_DOMAINS: dict[str, str] = { | |
| "stackoverflow": "stackoverflow.com", | |
| "serverfault": "serverfault.com", | |
| "superuser": "superuser.com", | |
| "askubuntu": "askubuntu.com", | |
| "meta.stackoverflow": "meta.stackoverflow.com", | |
| "meta.stackexchange": "meta.stackexchange.com", | |
| "mathoverflow.net": "mathoverflow.net", | |
| } | |
| def _question_url(site: str, question_id: Any) -> str: | |
| host = _KNOWN_DOMAINS.get(site, f"{site}.stackexchange.com") | |
| return f"https://{host}/questions/{question_id}" |
| async def _request_json(path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: | ||
| headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} | ||
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS, headers=headers) as client: | ||
| response = await client.get(f"{STACK_API_BASE_URL}{path}", params=params) | ||
| response.raise_for_status() |
There was a problem hiding this comment.
A new
httpx.AsyncClient is constructed and torn down on every request. This forgoes TCP connection reuse and means a full TLS handshake is performed on each call to the Stack Exchange API. Using a module-level (or lifespan-scoped) client gives connection pooling at no cost.
| async def _request_json(path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: | |
| headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} | |
| async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS, headers=headers) as client: | |
| response = await client.get(f"{STACK_API_BASE_URL}{path}", params=params) | |
| response.raise_for_status() | |
| _http_client: httpx.AsyncClient | None = None | |
| @app.on_event("startup") | |
| async def _startup() -> None: | |
| global _http_client | |
| _http_client = httpx.AsyncClient( | |
| timeout=REQUEST_TIMEOUT_SECONDS, | |
| headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, | |
| ) | |
| @app.on_event("shutdown") | |
| async def _shutdown() -> None: | |
| if _http_client: | |
| await _http_client.aclose() | |
| async def _request_json(path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: | |
| client = _http_client or httpx.AsyncClient( | |
| timeout=REQUEST_TIMEOUT_SECONDS, | |
| headers={"User-Agent": USER_AGENT, "Accept": "application/json"}, | |
| ) | |
| response = await client.get(f"{STACK_API_BASE_URL}{path}", params=params) | |
| response.raise_for_status() |
|
Addressed the Greptile feedback in commit f43e339:\n\n- surface Stack Exchange API backoff responses as graceful tool errors instead of ignoring them\n- map major Stack Exchange network site slugs to their real browser hosts, including serverfault.com, superuser.com, askubuntu.com, mathoverflow.net, and localized Stack Overflow sites\n- reuse a shared httpx.AsyncClient through FastAPI lifespan for connection pooling\n\nValidation rerun:\n- python -m py_compile plugins/omi-stack-overflow-app/main.py\n- git diff --check\n- FastAPI TestClient manifest/lifespan checks\n- explicit URL mapping checks for Stack Overflow, Server Fault, Super User, Ask Ubuntu, MathOverflow, localized Stack Overflow, and default stackexchange.com sites\n- mocked Stack Exchange backoff handling check\n- live search_questions smoke check against the Stack Exchange API |
kodjima33
left a comment
There was a problem hiding this comment.
thanks for adding the Stack Overflow app
Summary\n- add a standalone Stack Overflow / Stack Exchange Omi app under plugins/omi-stack-overflow-app\n- expose no-auth chat tools for question search, question details, and top answers\n- include deployment files and setup docs for Railway/Heroku-style hosting\n\n## Validation\n- python -m py_compile plugins/omi-stack-overflow-app/main.py\n- git diff --check\n- pip install -r plugins/omi-stack-overflow-app/requirements.txt in Python 3.11 venv\n- FastAPI TestClient manifest checks\n- helper checks for limit, tag, and site sanitization\n- live search_questions smoke check against Stack Exchange API\n- live get_top_answers smoke check against Stack Exchange API\n- gitleaks detect --source plugins/omi-stack-overflow-app --no-git --redact --verbose