Skip to content

Commit e24fd04

Browse files
committed
feat(gatekeeper): refactor to async functions and update dependencies
- Changed dependency from `requests` to `httpx`. - Updated HTTP client from `requests` to `httpx` for asynchronous capabilities. - Converted completion functions for OpenAI, Anthropic, Gemini, OpenRouter, and Vertex AI to async. - Added a new check in documentation to ensure async functions are only decorated when `asyncio_mode` is not set to auto.
1 parent f382c0b commit e24fd04

21 files changed

Lines changed: 175 additions & 99 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ make verify # All checks (required before commit)
2929
- Use pytest-mock (`mocker` fixture) for mocking instead of `unittest.mock` imports
3030
- Use `autospec=True` when patching; `spec=<object>` with Mock
3131
- 100% patch coverage for new code
32+
- Check the project config and test runners and only decorate async functions if the ascyio_mode is not set to auto.
3233

3334
**Security (Critical):**
3435
- All tools must be read-only with `readOnlyHint=True`

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ requires-python = ">=3.10"
1717
dependencies = [
1818
"asyncssh[bcrypt] >= 2.22.0",
1919
"fastmcp >= 3.2.4",
20-
"requests>=2.32.0",
20+
"httpx>=0.28.1",
2121
"pydantic-settings >= 2.12.0",
2222
"pydantic >= 2.12.5",
2323
# pydocket is incompatible with newer versions of fakeredis.

src/linux_mcp_server/gatekeeper/anthropic_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,15 @@ def extract_messages_text(response: dict[str, Any]) -> str:
6666
return ""
6767

6868

69-
def complete_anthropic(prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> GatekeeperCompletion:
69+
async def complete_anthropic(
70+
prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS
71+
) -> GatekeeperCompletion:
7072
headers = {
7173
"x-api-key": _get_anthropic_api_key(),
7274
"anthropic-version": ANTHROPIC_API_VERSION,
7375
"Content-Type": "application/json",
7476
}
75-
response = post_json(
77+
response = await post_json(
7678
provider="anthropic",
7779
url=ANTHROPIC_API_URL,
7880
headers=headers,

src/linux_mcp_server/gatekeeper/check_run_script.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ async def check_run_script_with_stats(
222222
time_before = time.perf_counter()
223223
try:
224224
completion = await asyncio.wait_for(
225-
asyncio.to_thread(complete_gatekeeper, prompt, max_tokens=GATEKEEPER_MAX_TOKENS),
225+
complete_gatekeeper(prompt, max_tokens=GATEKEEPER_MAX_TOKENS),
226226
timeout=GATEKEEPER_TIMEOUT,
227227
)
228228
except asyncio.TimeoutError:

src/linux_mcp_server/gatekeeper/gemini_client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,12 @@ def extract_gemini_text(response: dict[str, Any]) -> str:
6464
return text.strip() if isinstance(text, str) else ""
6565

6666

67-
def complete_gemini(prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> GatekeeperCompletion:
67+
async def complete_gemini(
68+
prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS
69+
) -> GatekeeperCompletion:
6870
model = normalize_model_id(CONFIG.gatekeeper.model or "")
6971
api_key = _get_google_api_key()
70-
response = post_json(
72+
response = await post_json(
7173
provider="gemini",
7274
url=f"{GOOGLE_AI_BASE_URL}/models/{model}:generateContent?key={api_key}",
7375
headers={"Content-Type": "application/json"},

src/linux_mcp_server/gatekeeper/http_utils.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from typing import Any
44

5-
import requests
5+
import httpx
66

77

88
DEFAULT_TIMEOUT_SECONDS = 120
99

10+
HTTP_CLIENT = httpx.AsyncClient()
11+
1012

1113
class GatekeeperHTTPError(RuntimeError):
1214
"""Raised when an LLM provider returns an error response."""
@@ -19,16 +21,16 @@ def __init__(self, provider: str, status_code: int, body: str):
1921
self.body = body
2022

2123

22-
def post_json(
24+
async def post_json(
2325
*,
2426
provider: str,
2527
url: str,
2628
headers: dict[str, str],
2729
body: dict[str, Any],
2830
timeout: int = DEFAULT_TIMEOUT_SECONDS,
2931
) -> dict[str, Any]:
30-
response = requests.post(url, headers=headers, json=body, timeout=timeout)
31-
if not response.ok:
32+
response = await HTTP_CLIENT.post(url, headers=headers, json=body, timeout=timeout)
33+
if not response.is_success:
3234
raise GatekeeperHTTPError(provider, response.status_code, response.text)
3335
return response.json()
3436

src/linux_mcp_server/gatekeeper/llm.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ def resolve_provider() -> GatekeeperProvider:
3333
return _infer_provider_from_model(CONFIG.gatekeeper.model)
3434

3535

36-
def complete_gatekeeper(prompt: str, *, max_tokens: int) -> GatekeeperCompletion:
36+
async def complete_gatekeeper(prompt: str, *, max_tokens: int) -> GatekeeperCompletion:
3737
provider = resolve_provider()
3838
match provider:
3939
case GatekeeperProvider.OPENAI:
40-
completion = complete_openai(prompt, max_tokens=max_tokens)
40+
completion = await complete_openai(prompt, max_tokens=max_tokens)
4141
case GatekeeperProvider.ANTHROPIC:
42-
completion = complete_anthropic(prompt, max_tokens=max_tokens)
42+
completion = await complete_anthropic(prompt, max_tokens=max_tokens)
4343
case GatekeeperProvider.GEMINI:
44-
completion = complete_gemini(prompt, max_tokens=max_tokens)
44+
completion = await complete_gemini(prompt, max_tokens=max_tokens)
4545
case GatekeeperProvider.OPENROUTER:
46-
completion = complete_openrouter(prompt, max_tokens=max_tokens)
46+
completion = await complete_openrouter(prompt, max_tokens=max_tokens)
4747
case GatekeeperProvider.VERTEX_AI:
48-
completion = complete_vertex_ai(prompt, max_tokens=max_tokens)
48+
completion = await complete_vertex_ai(prompt, max_tokens=max_tokens)
4949
case _: # pragma: no cover
5050
raise ValueError(f"Unsupported gatekeeper provider: {provider}")
5151

src/linux_mcp_server/gatekeeper/openai_client.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,9 @@ def extract_chat_completions_text(response: dict[str, Any]) -> str:
123123
return (content or "").strip() if isinstance(content, str) else ""
124124

125125

126-
def complete_openai(prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> GatekeeperCompletion:
126+
async def complete_openai(
127+
prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS
128+
) -> GatekeeperCompletion:
127129
base_url = _get_openai_base_url()
128130
headers = {
129131
**_openai_auth_headers(),
@@ -133,7 +135,7 @@ def complete_openai(prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIME
133135
# Try the Responses API first, falling back to Chat Completions if it's not available.
134136
if not _prefers_openai_chat_completions(base_url):
135137
try:
136-
response = post_json(
138+
response = await post_json(
137139
provider="openai",
138140
url=f"{base_url}/responses",
139141
headers=headers,
@@ -150,7 +152,7 @@ def complete_openai(prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIME
150152
if exc.status_code not in {404, 405}:
151153
raise
152154

153-
response = post_json(
155+
response = await post_json(
154156
provider="openai",
155157
url=f"{base_url}/chat/completions",
156158
headers=headers,

src/linux_mcp_server/gatekeeper/openrouter_client.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,15 +87,15 @@ def _extract_chat_completions_text(response: dict[str, Any]) -> str:
8787
return (content or "").strip() if isinstance(content, str) else ""
8888

8989

90-
def complete_openrouter(
90+
async def complete_openrouter(
9191
prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS
9292
) -> GatekeeperCompletion:
9393
base_url = _get_openrouter_base_url()
9494
headers = {
9595
**_openrouter_auth_headers(),
9696
"Content-Type": "application/json",
9797
}
98-
response = post_json(
98+
response = await post_json(
9999
provider="openrouter",
100100
url=f"{base_url}/chat/completions",
101101
headers=headers,

src/linux_mcp_server/gatekeeper/pricing.py

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import logging
55
import os
66

7+
from functools import cache
78
from pathlib import Path
89
from typing import Any
910
from urllib.parse import urlparse
1011

11-
import requests
12+
import httpx
1213

1314
from linux_mcp_server.config import CONFIG
1415
from linux_mcp_server.config import GatekeeperProvider
@@ -84,23 +85,19 @@ def _load_models_dev_fallback() -> dict[str, Any]:
8485
return json.load(handle)
8586

8687

88+
@cache
8789
def _load_models_dev_pricing() -> dict[str, Any]:
8890
"""Loads the pricing from the models.dev API, falling back to the vendored fallback if the API is not available."""
89-
global _models_dev_cache
90-
if _models_dev_cache is not None:
91-
return _models_dev_cache
92-
9391
try:
94-
response = requests.get(MODELS_DEV_API_URL, timeout=10)
95-
response.raise_for_status()
96-
_models_dev_cache = response.json()
97-
logger.debug("Loaded gatekeeper pricing from models.dev API")
92+
with httpx.Client(timeout=10) as client:
93+
response = client.get(MODELS_DEV_API_URL)
94+
response.raise_for_status()
95+
pricing = response.json()
96+
logger.debug("Loaded gatekeeper pricing from models.dev API", extra={"pricing": pricing})
97+
return pricing
9898
except Exception as exc:
9999
logger.debug("Failed to fetch models.dev pricing (%s); using vendored fallback", exc)
100-
_models_dev_cache = _load_models_dev_fallback()
101-
102-
assert _models_dev_cache is not None
103-
return _models_dev_cache
100+
return _load_models_dev_fallback()
104101

105102

106103
def _lookup_models_dev_cost(provider_key: str, model: str) -> tuple[float, float] | None:

0 commit comments

Comments
 (0)