Skip to content

Commit e85f661

Browse files
committed
refactor(gatekeeper): Drop Chat Completions API from OpenAI client
1 parent 041db48 commit e85f661

5 files changed

Lines changed: 23 additions & 143 deletions

File tree

Lines changed: 5 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
"""OpenAI Responses and Chat Completions clients for the gatekeeper."""
1+
"""OpenAI Responses API client for the gatekeeper."""
22

33
import os
44

55
from typing import Any
6-
from urllib.parse import urlparse
76

87
from linux_mcp_server.config import CONFIG
98
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 GatekeeperHTTPError
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.schema import openai_response_format
1512
from linux_mcp_server.gatekeeper.schema import openai_text_format
16-
from linux_mcp_server.gatekeeper.usage import extract_openai_chat_completions_usage
1713
from linux_mcp_server.gatekeeper.usage import extract_openai_responses_usage
1814
from linux_mcp_server.models import GatekeeperCompletion
1915

@@ -43,26 +39,6 @@ def _get_openai_base_url() -> str:
4339
return (configured or os.environ.get("OPENAI_API_BASE") or OPENAI_DEFAULT_BASE_URL).rstrip("/")
4440

4541

46-
def _prefers_openai_chat_completions(base_url: str) -> bool:
47-
"""Hosts known to expose only Chat Completions, not the Responses API."""
48-
path = urlparse(base_url).path or ""
49-
return "/endpoints/openapi" in path
50-
51-
52-
def _openai_template_kwargs() -> dict[str, Any]:
53-
if CONFIG.gatekeeper.openai is None:
54-
return {}
55-
return CONFIG.gatekeeper.openai.template_kwargs
56-
57-
58-
def _apply_chat_completions_extras(body: dict[str, Any]) -> dict[str, Any]:
59-
"""Merge template_kwargs into Chat Completions bodies (llama.cpp, etc.)."""
60-
template_kwargs = _openai_template_kwargs()
61-
if template_kwargs:
62-
body["chat_template_kwargs"] = template_kwargs
63-
return body
64-
65-
6642
def _build_responses_body(prompt: str, *, max_tokens: int) -> dict[str, Any]:
6743
body: dict[str, Any] = {
6844
"model": normalize_model_id(CONFIG.gatekeeper.model or ""),
@@ -79,21 +55,6 @@ def _build_responses_body(prompt: str, *, max_tokens: int) -> dict[str, Any]:
7955
return body
8056

8157

82-
def build_chat_completions_body(prompt: str, *, max_tokens: int) -> dict[str, Any]:
83-
body: dict[str, Any] = {
84-
"model": normalize_model_id(CONFIG.gatekeeper.model or ""),
85-
"messages": [{"role": "user", "content": prompt}],
86-
"max_completion_tokens": max_tokens,
87-
"temperature": CONFIG.gatekeeper.temperature,
88-
}
89-
if CONFIG.gatekeeper.structured_output:
90-
body["response_format"] = openai_response_format()
91-
reasoning_effort = CONFIG.gatekeeper.reasoning_effort
92-
if reasoning_effort is not None:
93-
body["reasoning_effort"] = reasoning_effort.value
94-
return _apply_chat_completions_extras(body)
95-
96-
9758
def _extract_responses_text(response: dict[str, Any]) -> str:
9859
output_text = response.get("output_text")
9960
if isinstance(output_text, str) and output_text.strip():
@@ -114,15 +75,6 @@ def _extract_responses_text(response: dict[str, Any]) -> str:
11475
return "".join(chunks).strip()
11576

11677

117-
def extract_chat_completions_text(response: dict[str, Any]) -> str:
118-
choices = response.get("choices", [])
119-
if not choices:
120-
return ""
121-
message = choices[0].get("message", {})
122-
content = message.get("content")
123-
return (content or "").strip() if isinstance(content, str) else ""
124-
125-
12678
async def complete_openai(
12779
prompt: str, *, max_tokens: int, timeout: int = DEFAULT_TIMEOUT_SECONDS
12880
) -> GatekeeperCompletion:
@@ -131,37 +83,16 @@ async def complete_openai(
13183
**_openai_auth_headers(),
13284
"Content-Type": "application/json",
13385
}
134-
135-
# Try the Responses API first, falling back to Chat Completions if it's not available.
136-
if not _prefers_openai_chat_completions(base_url):
137-
try:
138-
response = await post_json(
139-
provider="openai",
140-
url=f"{base_url}/responses",
141-
headers=headers,
142-
body=_build_responses_body(prompt, max_tokens=max_tokens),
143-
timeout=timeout,
144-
)
145-
usage = extract_openai_responses_usage(response)
146-
return GatekeeperCompletion(
147-
text=_extract_responses_text(response),
148-
prompt_tokens=usage.input_tokens,
149-
completion_tokens=usage.output_tokens,
150-
)
151-
except GatekeeperHTTPError as exc:
152-
if exc.status_code not in {404, 405}:
153-
raise
154-
15586
response = await post_json(
15687
provider="openai",
157-
url=f"{base_url}/chat/completions",
88+
url=f"{base_url}/responses",
15889
headers=headers,
159-
body=build_chat_completions_body(prompt, max_tokens=max_tokens),
90+
body=_build_responses_body(prompt, max_tokens=max_tokens),
16091
timeout=timeout,
16192
)
162-
usage = extract_openai_chat_completions_usage(response)
93+
usage = extract_openai_responses_usage(response)
16394
return GatekeeperCompletion(
164-
text=extract_chat_completions_text(response),
95+
text=_extract_responses_text(response),
16596
prompt_tokens=usage.input_tokens,
16697
completion_tokens=usage.output_tokens,
16798
)

src/linux_mcp_server/gatekeeper/vertex_ai_client.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@
1212
from linux_mcp_server.gatekeeper.http_utils import DEFAULT_TIMEOUT_SECONDS
1313
from linux_mcp_server.gatekeeper.http_utils import normalize_model_id
1414
from linux_mcp_server.gatekeeper.http_utils import post_json
15-
from linux_mcp_server.gatekeeper.openai_client import build_chat_completions_body
16-
from linux_mcp_server.gatekeeper.openai_client import extract_chat_completions_text
15+
from linux_mcp_server.gatekeeper.openai_client import _build_responses_body
16+
from linux_mcp_server.gatekeeper.openai_client import _extract_responses_text
1717
from linux_mcp_server.gatekeeper.usage import extract_anthropic_usage
1818
from linux_mcp_server.gatekeeper.usage import extract_gemini_usage
19-
from linux_mcp_server.gatekeeper.usage import extract_openai_chat_completions_usage
19+
from linux_mcp_server.gatekeeper.usage import extract_openai_responses_usage
2020
from linux_mcp_server.models import GatekeeperCompletion
2121

2222

@@ -104,14 +104,14 @@ async def _complete_openai_compatible_on_vertex(prompt: str, *, max_tokens: int,
104104
base_url = _get_vertex_openapi_base_url()
105105
response = await post_json(
106106
provider="openai",
107-
url=f"{base_url}/chat/completions",
107+
url=f"{base_url}/responses",
108108
headers={**_vertex_auth_headers(), "Content-Type": "application/json"},
109-
body=build_chat_completions_body(prompt, max_tokens=max_tokens),
109+
body=_build_responses_body(prompt, max_tokens=max_tokens),
110110
timeout=timeout,
111111
)
112-
usage = extract_openai_chat_completions_usage(response)
112+
usage = extract_openai_responses_usage(response)
113113
return GatekeeperCompletion(
114-
text=extract_chat_completions_text(response),
114+
text=_extract_responses_text(response),
115115
prompt_tokens=usage.input_tokens,
116116
completion_tokens=usage.output_tokens,
117117
)

tests/gatekeeper/test_check_run_script.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ async def test_vertex_ai_provider_uses_openapi_base_url(self, mocker):
232232
mock_post = mocker.patch(
233233
"linux_mcp_server.gatekeeper.vertex_ai_client.post_json",
234234
new_callable=mocker.AsyncMock,
235-
return_value={"choices": [{"message": {"content": '{"status": "OK"}'}}]},
235+
return_value={"output_text": '{"status": "OK", "detail": ""}'},
236236
)
237237
mocker.patch.object(
238238
CONFIG,
@@ -251,5 +251,5 @@ async def test_vertex_ai_provider_uses_openapi_base_url(self, mocker):
251251

252252
await check_run_script(description="test", script_type="bash", script="echo hi", readonly=True)
253253

254-
assert mock_post.call_args.kwargs["url"].endswith("/chat/completions")
254+
assert mock_post.call_args.kwargs["url"].endswith("/responses")
255255
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer gcp-token"

tests/gatekeeper/test_openai_client.py

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,6 @@
88
from linux_mcp_server.gatekeeper import openai_client
99
from linux_mcp_server.gatekeeper.http_utils import GatekeeperHTTPError
1010
from linux_mcp_server.gatekeeper.openai_client import _openai_reasoning_block
11-
from linux_mcp_server.gatekeeper.openai_client import _prefers_openai_chat_completions
12-
13-
14-
@pytest.mark.parametrize(
15-
"base_url,expected",
16-
[
17-
("https://api.openai.com/v1", False),
18-
("http://localhost:11434/v1", False),
19-
("https://example.com/v1", False),
20-
(
21-
"https://aiplatform.googleapis.com/v1/projects/p/locations/global/endpoints/openapi",
22-
True,
23-
),
24-
],
25-
)
26-
def test_prefers_openai_chat_completions(base_url, expected):
27-
assert _prefers_openai_chat_completions(base_url) is expected
2811

2912

3013
def test_openai_reasoning_block_none():
@@ -71,7 +54,7 @@ async def test_complete_openai_uses_responses_api(self, gatekeeper_config, mocke
7154
assert body["reasoning"] == {"effort": "low"}
7255
assert body["text"]["format"]["type"] == "json_schema"
7356

74-
async def test_complete_openai_uses_responses_api_for_ollama(self, gatekeeper_config, mocker):
57+
async def test_complete_openai_uses_responses_api_for_custom_base_url(self, gatekeeper_config, mocker):
7558
gatekeeper_config.openai = OpenAIGatekeeperConfig(base_url="http://localhost:11434/v1")
7659
mock_post = mocker.patch(
7760
"linux_mcp_server.gatekeeper.openai_client.post_json",
@@ -84,30 +67,15 @@ async def test_complete_openai_uses_responses_api_for_ollama(self, gatekeeper_co
8467
assert result.text == '{"status": "OK", "detail": ""}'
8568
assert mock_post.call_args.kwargs["url"] == "http://localhost:11434/v1/responses"
8669

87-
async def test_complete_openai_falls_back_to_chat_completions_on_404(self, gatekeeper_config, mocker):
88-
gatekeeper_config.openai = OpenAIGatekeeperConfig(base_url="https://models.example.com/v1")
89-
mock_post = mocker.patch(
70+
async def test_complete_openai_propagates_responses_api_errors(self, gatekeeper_config, mocker):
71+
mocker.patch(
9072
"linux_mcp_server.gatekeeper.openai_client.post_json",
9173
new_callable=mocker.AsyncMock,
92-
side_effect=[
93-
GatekeeperHTTPError("openai", 404, "not found"),
94-
{
95-
"choices": [{"message": {"content": '{"status": "OK", "detail": ""}'}}],
96-
"usage": {"prompt_tokens": 9, "completion_tokens": 2},
97-
},
98-
],
74+
side_effect=GatekeeperHTTPError("openai", 404, "not found"),
9975
)
10076

101-
result = await openai_client.complete_openai("prompt", max_tokens=8000)
102-
103-
assert result.text == '{"status": "OK", "detail": ""}'
104-
assert result.prompt_tokens == 9
105-
assert result.completion_tokens == 2
106-
assert mock_post.call_args_list[0].kwargs["url"] == "https://models.example.com/v1/responses"
107-
assert mock_post.call_args_list[1].kwargs["url"] == "https://models.example.com/v1/chat/completions"
108-
body = mock_post.call_args_list[1].kwargs["body"]
109-
assert body["response_format"]["type"] == "json_schema"
110-
assert body["reasoning_effort"] == "low"
77+
with pytest.raises(GatekeeperHTTPError, match="not found"):
78+
await openai_client.complete_openai("prompt", max_tokens=8000)
11179

11280
async def test_structured_output_disabled(self, gatekeeper_config, mocker):
11381
gatekeeper_config.structured_output = False
@@ -121,22 +89,3 @@ async def test_structured_output_disabled(self, gatekeeper_config, mocker):
12189

12290
body = mock_post.call_args.kwargs["body"]
12391
assert "text" not in body
124-
125-
async def test_template_kwargs_on_chat_completions(self, gatekeeper_config, mocker):
126-
gatekeeper_config.openai = OpenAIGatekeeperConfig(
127-
base_url="https://models.example.com/v1",
128-
template_kwargs={"enable_thinking": False},
129-
)
130-
mock_post = mocker.patch(
131-
"linux_mcp_server.gatekeeper.openai_client.post_json",
132-
new_callable=mocker.AsyncMock,
133-
side_effect=[
134-
GatekeeperHTTPError("openai", 404, "not found"),
135-
{"choices": [{"message": {"content": '{"status": "OK"}'}}]},
136-
],
137-
)
138-
139-
await openai_client.complete_openai("prompt", max_tokens=8000)
140-
141-
body = mock_post.call_args_list[1].kwargs["body"]
142-
assert body["chat_template_kwargs"] == {"enable_thinking": False}

tests/gatekeeper/test_vertex_ai_client.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,13 @@ async def test_complete_openai_compatible_on_vertex(self, gatekeeper_config, moc
9191
mock_post = mocker.patch(
9292
"linux_mcp_server.gatekeeper.vertex_ai_client.post_json",
9393
new_callable=mocker.AsyncMock,
94-
return_value={"choices": [{"message": {"content": '{"status": "OK"}'}}]},
94+
return_value={"output_text": '{"status": "OK"}'},
9595
)
9696

9797
result = await vertex_ai_client.complete_vertex_ai("prompt", max_tokens=8000)
9898

9999
assert result.text == '{"status": "OK"}'
100-
assert mock_post.call_args.kwargs["url"].endswith("/chat/completions")
100+
assert mock_post.call_args.kwargs["url"].endswith("/responses")
101101
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer gcp-token"
102102

103103
async def test_custom_openapi_base_url(self, gatekeeper_config, mocker):
@@ -109,7 +109,7 @@ async def test_custom_openapi_base_url(self, gatekeeper_config, mocker):
109109
mock_post = mocker.patch(
110110
"linux_mcp_server.gatekeeper.vertex_ai_client.post_json",
111111
new_callable=mocker.AsyncMock,
112-
return_value={"choices": [{"message": {"content": '{"status": "OK"}'}}]},
112+
return_value={"output_text": '{"status": "OK"}'},
113113
)
114114

115115
await vertex_ai_client.complete_vertex_ai("prompt", max_tokens=8000)

0 commit comments

Comments
 (0)