Skip to content

Commit 1d5aee7

Browse files
committed
refactor: reorganize provider-specific funcions
Move provider-specific methods out of http_utils into their relevant client file.
1 parent 4115e53 commit 1d5aee7

14 files changed

Lines changed: 290 additions & 300 deletions

src/linux_mcp_server/gatekeeper/anthropic_client.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,44 @@
11
"""Anthropic Messages API client for the gatekeeper."""
22

3+
import os
4+
35
from typing import Any
46

57
from linux_mcp_server.config import CONFIG
6-
from linux_mcp_server.gatekeeper.http_utils import ANTHROPIC_API_URL
7-
from linux_mcp_server.gatekeeper.http_utils import ANTHROPIC_API_VERSION
8-
from linux_mcp_server.gatekeeper.http_utils import ANTHROPIC_DEFAULT_MAX_TOKENS
9-
from linux_mcp_server.gatekeeper.http_utils import anthropic_thinking_block
8+
from linux_mcp_server.config import ReasoningEffort
109
from linux_mcp_server.gatekeeper.http_utils import DEFAULT_TIMEOUT_SECONDS
11-
from linux_mcp_server.gatekeeper.http_utils import get_anthropic_api_key
1210
from linux_mcp_server.gatekeeper.http_utils import normalize_model_id
1311
from linux_mcp_server.gatekeeper.http_utils import post_json
14-
from linux_mcp_server.gatekeeper.llm import GatekeeperCompletion
1512
from linux_mcp_server.gatekeeper.schema import anthropic_output_config
13+
from linux_mcp_server.models import GatekeeperCompletion
14+
15+
16+
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
17+
ANTHROPIC_API_VERSION = "2023-06-01"
18+
ANTHROPIC_DEFAULT_MAX_TOKENS = 4096
19+
20+
21+
def _get_anthropic_api_key() -> str:
22+
api_key = os.environ.get("ANTHROPIC_API_KEY")
23+
if not api_key:
24+
raise ValueError("ANTHROPIC_API_KEY is required for Anthropic gatekeeper provider.")
25+
return api_key
26+
27+
28+
def _anthropic_thinking_block(reasoning_effort: ReasoningEffort | None) -> dict[str, Any] | None:
29+
if reasoning_effort is None or reasoning_effort in {ReasoningEffort.NONE, ReasoningEffort.DEFAULT}:
30+
return None
31+
budget_by_effort = {
32+
ReasoningEffort.MINIMAL: 1024,
33+
ReasoningEffort.LOW: 4096,
34+
ReasoningEffort.MEDIUM: 8192,
35+
ReasoningEffort.HIGH: 16384,
36+
ReasoningEffort.XHIGH: 32768,
37+
}
38+
budget = budget_by_effort.get(reasoning_effort)
39+
if budget is None:
40+
return None
41+
return {"type": "enabled", "budget_tokens": budget}
1642

1743

1844
def build_messages_body(prompt: str, *, include_model: bool) -> dict[str, Any]:
@@ -25,7 +51,7 @@ def build_messages_body(prompt: str, *, include_model: bool) -> dict[str, Any]:
2551
body["model"] = normalize_model_id(CONFIG.gatekeeper.model or "")
2652
if CONFIG.gatekeeper.structured_output:
2753
body["output_config"] = anthropic_output_config()
28-
thinking = anthropic_thinking_block(CONFIG.gatekeeper.reasoning_effort)
54+
thinking = _anthropic_thinking_block(CONFIG.gatekeeper.reasoning_effort)
2955
if thinking is not None:
3056
body["thinking"] = thinking
3157
return body
@@ -42,7 +68,7 @@ def extract_messages_text(response: dict[str, Any]) -> str:
4268

4369
def complete_anthropic(prompt: str, *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> GatekeeperCompletion:
4470
headers = {
45-
"x-api-key": get_anthropic_api_key(),
71+
"x-api-key": _get_anthropic_api_key(),
4672
"anthropic-version": ANTHROPIC_API_VERSION,
4773
"Content-Type": "application/json",
4874
}

src/linux_mcp_server/gatekeeper/gemini_client.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
11
"""Gemini generateContent client for the gatekeeper."""
22

3+
import os
4+
35
from typing import Any
46

57
from linux_mcp_server.config import CONFIG
8+
from linux_mcp_server.config import ReasoningEffort
69
from linux_mcp_server.gatekeeper.http_utils import DEFAULT_TIMEOUT_SECONDS
7-
from linux_mcp_server.gatekeeper.http_utils import gemini_thinking_level
8-
from linux_mcp_server.gatekeeper.http_utils import get_google_api_key
9-
from linux_mcp_server.gatekeeper.http_utils import GOOGLE_AI_BASE_URL
1010
from linux_mcp_server.gatekeeper.http_utils import normalize_model_id
1111
from linux_mcp_server.gatekeeper.http_utils import post_json
12-
from linux_mcp_server.gatekeeper.llm import GatekeeperCompletion
1312
from linux_mcp_server.gatekeeper.schema import gemini_generation_config
13+
from linux_mcp_server.models import GatekeeperCompletion
14+
15+
16+
GOOGLE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
17+
18+
19+
def _get_google_api_key() -> str:
20+
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
21+
if not api_key:
22+
raise ValueError("GOOGLE_API_KEY or GEMINI_API_KEY is required for Gemini direct backend.")
23+
return api_key
24+
25+
26+
def _gemini_thinking_level(reasoning_effort: ReasoningEffort | None) -> str | None:
27+
if reasoning_effort is None or reasoning_effort in {ReasoningEffort.NONE, ReasoningEffort.DEFAULT}:
28+
return None
29+
mapping = {
30+
ReasoningEffort.MINIMAL: "MINIMAL",
31+
ReasoningEffort.LOW: "LOW",
32+
ReasoningEffort.MEDIUM: "MEDIUM",
33+
ReasoningEffort.HIGH: "HIGH",
34+
ReasoningEffort.XHIGH: "HIGH",
35+
}
36+
return mapping.get(reasoning_effort)
1437

1538

1639
def build_gemini_body(prompt: str) -> dict[str, Any]:
1740
generation_config = gemini_generation_config(
1841
temperature=CONFIG.gatekeeper.temperature,
1942
structured_output=CONFIG.gatekeeper.structured_output,
2043
)
21-
thinking_level = gemini_thinking_level(CONFIG.gatekeeper.reasoning_effort)
44+
thinking_level = _gemini_thinking_level(CONFIG.gatekeeper.reasoning_effort)
2245
if thinking_level is not None:
2346
generation_config["thinkingConfig"] = {"thinkingLevel": thinking_level}
2447
return {
@@ -41,7 +64,7 @@ def extract_gemini_text(response: dict[str, Any]) -> str:
4164

4265
def complete_gemini(prompt: str, *, timeout: int = DEFAULT_TIMEOUT_SECONDS) -> GatekeeperCompletion:
4366
model = normalize_model_id(CONFIG.gatekeeper.model or "")
44-
api_key = get_google_api_key()
67+
api_key = _get_google_api_key()
4568
response = post_json(
4669
provider="gemini",
4770
url=f"{GOOGLE_AI_BASE_URL}/models/{model}:generateContent?key={api_key}",
Lines changed: 0 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,12 @@
11
"""Shared helpers for gatekeeper HTTP clients."""
22

3-
import os
4-
53
from typing import Any
6-
from typing import Literal
7-
from urllib.parse import urlparse
84

95
import requests
106

11-
from linux_mcp_server.config import CONFIG
12-
from linux_mcp_server.config import ReasoningEffort
13-
147

158
DEFAULT_TIMEOUT_SECONDS = 120
169

17-
OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1"
18-
OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
19-
20-
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
21-
ANTHROPIC_API_VERSION = "2023-06-01"
22-
ANTHROPIC_VERTEX_VERSION = "vertex-2023-10-16"
23-
ANTHROPIC_DEFAULT_MAX_TOKENS = 4096
24-
25-
GOOGLE_AI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
26-
2710

2811
class GatekeeperHTTPError(RuntimeError):
2912
"""Raised when an LLM provider returns an error response."""
@@ -50,136 +33,8 @@ def post_json(
5033
return response.json()
5134

5235

53-
def get_openai_base_url() -> str:
54-
configured = CONFIG.gatekeeper.openai.base_url if CONFIG.gatekeeper.openai else None
55-
return (configured or os.environ.get("OPENAI_API_BASE") or OPENAI_DEFAULT_BASE_URL).rstrip("/")
56-
57-
58-
def prefers_openai_chat_completions(base_url: str) -> bool:
59-
"""Hosts known to expose only Chat Completions, not the Responses API."""
60-
path = urlparse(base_url).path or ""
61-
return "/endpoints/openapi" in path
62-
63-
6436
def normalize_model_id(model: str) -> str:
6537
for prefix in ("openai/", "anthropic/", "vertex_ai/", "gemini/"):
6638
if model.startswith(prefix):
6739
return model[len(prefix) :]
6840
return model
69-
70-
71-
def normalize_openrouter_model_id(model: str) -> str:
72-
if model.startswith("openrouter/"):
73-
return model[len("openrouter/") :]
74-
return model
75-
76-
77-
def get_openrouter_base_url() -> str:
78-
configured = CONFIG.gatekeeper.openrouter.base_url if CONFIG.gatekeeper.openrouter else None
79-
return (configured or OPENROUTER_DEFAULT_BASE_URL).rstrip("/")
80-
81-
82-
def vertex_api_style(model: str) -> Literal["anthropic", "gemini", "openai_compatible"]:
83-
normalized = normalize_model_id(model)
84-
if normalized.startswith("claude"):
85-
return "anthropic"
86-
if normalized.startswith("gemini"):
87-
return "gemini"
88-
return "openai_compatible"
89-
90-
91-
def get_vertex_openapi_base_url() -> str:
92-
cfg = CONFIG.gatekeeper.vertex_ai
93-
if cfg and cfg.base_url:
94-
return cfg.base_url.rstrip("/")
95-
from linux_mcp_server.gatekeeper.gcp_auth import get_gcp_location
96-
from linux_mcp_server.gatekeeper.gcp_auth import get_gcp_project
97-
98-
project = get_gcp_project()
99-
location = get_gcp_location()
100-
return f"https://aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/endpoints/openapi"
101-
102-
103-
def vertex_auth_headers() -> dict[str, str]:
104-
from linux_mcp_server.gatekeeper.gcp_auth import get_gcp_access_token
105-
106-
return {"Authorization": f"Bearer {get_gcp_access_token()}"}
107-
108-
109-
def openai_reasoning_block(reasoning_effort: ReasoningEffort | None) -> dict[str, Any] | None:
110-
if reasoning_effort is None or reasoning_effort == ReasoningEffort.DEFAULT:
111-
return None
112-
return {"effort": reasoning_effort.value}
113-
114-
115-
def openrouter_reasoning_block(reasoning_effort: ReasoningEffort | None) -> dict[str, Any] | None:
116-
if reasoning_effort is None or reasoning_effort == ReasoningEffort.DEFAULT:
117-
return None
118-
if reasoning_effort == ReasoningEffort.NONE:
119-
return {"enabled": False}
120-
return {"enabled": True, "effort": reasoning_effort.value}
121-
122-
123-
def anthropic_thinking_block(reasoning_effort: ReasoningEffort | None) -> dict[str, Any] | None:
124-
if reasoning_effort is None or reasoning_effort in {ReasoningEffort.NONE, ReasoningEffort.DEFAULT}:
125-
return None
126-
budget_by_effort = {
127-
ReasoningEffort.MINIMAL: 1024,
128-
ReasoningEffort.LOW: 4096,
129-
ReasoningEffort.MEDIUM: 8192,
130-
ReasoningEffort.HIGH: 16384,
131-
ReasoningEffort.XHIGH: 32768,
132-
}
133-
budget = budget_by_effort.get(reasoning_effort)
134-
if budget is None:
135-
return None
136-
return {"type": "enabled", "budget_tokens": budget}
137-
138-
139-
def gemini_thinking_level(reasoning_effort: ReasoningEffort | None) -> str | None:
140-
if reasoning_effort is None or reasoning_effort in {ReasoningEffort.NONE, ReasoningEffort.DEFAULT}:
141-
return None
142-
mapping = {
143-
ReasoningEffort.MINIMAL: "MINIMAL",
144-
ReasoningEffort.LOW: "LOW",
145-
ReasoningEffort.MEDIUM: "MEDIUM",
146-
ReasoningEffort.HIGH: "HIGH",
147-
ReasoningEffort.XHIGH: "HIGH",
148-
}
149-
return mapping.get(reasoning_effort)
150-
151-
152-
def get_openai_api_key() -> str:
153-
api_key = os.environ.get("OPENAI_API_KEY")
154-
if not api_key:
155-
raise ValueError("OPENAI_API_KEY is required for OpenAI gatekeeper provider.")
156-
return api_key
157-
158-
159-
def get_openrouter_api_key() -> str:
160-
api_key = os.environ.get("OPENROUTER_API_KEY")
161-
if not api_key:
162-
raise ValueError("OPENROUTER_API_KEY is required for OpenRouter gatekeeper provider.")
163-
return api_key
164-
165-
166-
def openrouter_auth_headers() -> dict[str, str]:
167-
return {"Authorization": f"Bearer {get_openrouter_api_key()}"}
168-
169-
170-
def get_anthropic_api_key() -> str:
171-
api_key = os.environ.get("ANTHROPIC_API_KEY")
172-
if not api_key:
173-
raise ValueError("ANTHROPIC_API_KEY is required for Anthropic gatekeeper provider.")
174-
return api_key
175-
176-
177-
def get_google_api_key() -> str:
178-
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
179-
if not api_key:
180-
raise ValueError("GOOGLE_API_KEY or GEMINI_API_KEY is required for Gemini direct backend.")
181-
return api_key
182-
183-
184-
def openai_auth_headers() -> dict[str, str]:
185-
return {"Authorization": f"Bearer {get_openai_api_key()}"}

src/linux_mcp_server/gatekeeper/llm.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,19 @@
22

33
import logging
44

5-
from pydantic import BaseModel
6-
75
from linux_mcp_server.config import CONFIG
86
from linux_mcp_server.config import GatekeeperProvider
97
from linux_mcp_server.gatekeeper.anthropic_client import complete_anthropic
108
from linux_mcp_server.gatekeeper.gemini_client import complete_gemini
119
from linux_mcp_server.gatekeeper.openai_client import complete_openai
1210
from linux_mcp_server.gatekeeper.openrouter_client import complete_openrouter
1311
from linux_mcp_server.gatekeeper.vertex_ai_client import complete_vertex_ai
12+
from linux_mcp_server.models import GatekeeperCompletion
1413

1514

1615
logger = logging.getLogger("linux-mcp-server")
1716

1817

19-
class GatekeeperCompletion(BaseModel):
20-
text: str
21-
prompt_tokens: int = 0
22-
completion_tokens: int = 0
23-
usage_cost: float | None = None
24-
25-
2618
def _infer_provider_from_model(model: str) -> GatekeeperProvider:
2719
if model.startswith("openrouter/"):
2820
return GatekeeperProvider.OPENROUTER

0 commit comments

Comments
 (0)