Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,15 @@ def _parse_session_key_header(

return raw, None

@staticmethod
def _response_visible_to_session_key(
stored: Dict[str, Any],
gateway_session_key: Optional[str],
) -> bool:
"""Return whether a stored response belongs to the caller's session key."""
owner_key = stored.get("gateway_session_key")
return not owner_key or owner_key == gateway_session_key

# ------------------------------------------------------------------
# Session DB helper
# ------------------------------------------------------------------
Expand Down Expand Up @@ -2882,6 +2891,7 @@ def _persist_response_snapshot(
"conversation_history": conversation_history_snapshot,
"instructions": instructions,
"session_id": session_id,
"gateway_session_key": gateway_session_key,
})
if conversation:
self._response_store.set_conversation(conversation, response_id)
Expand Down Expand Up @@ -3392,6 +3402,10 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
# Resolve conversation name to latest response_id
if conversation:
previous_response_id = self._response_store.get_conversation(conversation)
if previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is None or not self._response_visible_to_session_key(stored, gateway_session_key):
previous_response_id = None
# No error if conversation doesn't exist yet — it's a new conversation

# Normalize input to message list
Expand Down Expand Up @@ -3441,7 +3455,7 @@ async def _handle_responses(self, request: "web.Request") -> "web.Response":
stored_session_id = None
if not conversation_history and previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is None:
if stored is None or not self._response_visible_to_session_key(stored, gateway_session_key):
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
conversation_history = list(stored.get("conversation_history", []))
stored_session_id = stored.get("session_id")
Expand Down Expand Up @@ -3630,6 +3644,7 @@ async def _compute_response():
"conversation_history": full_history,
"instructions": instructions,
"session_id": session_id,
"gateway_session_key": gateway_session_key,
})
# Update conversation mapping so the next request with the same
# conversation name automatically chains to this response
Expand All @@ -3650,10 +3665,13 @@ async def _handle_get_response(self, request: "web.Request") -> "web.Response":
auth_err = self._check_auth(request)
if auth_err:
return auth_err
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err

response_id = request.match_info["response_id"]
stored = self._response_store.get(response_id)
if stored is None:
if stored is None or not self._response_visible_to_session_key(stored, gateway_session_key):
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)

return web.json_response(stored["response"])
Expand All @@ -3663,8 +3681,15 @@ async def _handle_delete_response(self, request: "web.Request") -> "web.Response
auth_err = self._check_auth(request)
if auth_err:
return auth_err
gateway_session_key, key_err = self._parse_session_key_header(request)
if key_err is not None:
return key_err

response_id = request.match_info["response_id"]
stored = self._response_store.get(response_id)
if stored is None or not self._response_visible_to_session_key(stored, gateway_session_key):
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)

deleted = self._response_store.delete(response_id)
if not deleted:
return web.json_response(_openai_error(f"Response not found: {response_id}"), status=404)
Expand Down Expand Up @@ -4394,11 +4419,12 @@ async def _handle_runs(self, request: "web.Request") -> "web.Response":
stored_session_id = None
if not conversation_history and previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored:
conversation_history = list(stored.get("conversation_history", []))
stored_session_id = stored.get("session_id")
if instructions is None:
instructions = stored.get("instructions")
if stored is None or not self._response_visible_to_session_key(stored, gateway_session_key):
return web.json_response(_openai_error(f"Previous response not found: {previous_response_id}"), status=404)
conversation_history = list(stored.get("conversation_history", []))
stored_session_id = stored.get("session_id")
if instructions is None:
instructions = stored.get("instructions")

# When input is a multi-message array, extract all but the last
# message as conversation history (the last becomes user_message).
Expand Down
124 changes: 124 additions & 0 deletions tests/gateway/test_api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
app.router.add_post("/v1/responses", adapter._handle_responses)
app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response)
app.router.add_delete("/v1/responses/{response_id}", adapter._handle_delete_response)
app.router.add_post("/v1/runs", adapter._handle_runs)
return app


Expand Down Expand Up @@ -3850,6 +3851,129 @@ async def test_responses_endpoint_accepts_session_key(self, auth_adapter):
call_kwargs = mock_run.call_args.kwargs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the analogous cross-key regression for /v1/runs with previous_response_id: this PR changes that handler too, but this test only covers /v1/responses retrieval, deletion, and chaining.

assert call_kwargs["gateway_session_key"] == "webui:chan-1"

@pytest.mark.asyncio
async def test_stored_responses_are_scoped_to_session_key(self, auth_adapter):
"""Stored Responses API records cannot be read, deleted, or chained from another session key."""
mock_result = {
"final_response": "private answer",
"messages": [{"role": "assistant", "content": "private answer"}],
"api_calls": 1,
}
owner_headers = {
"X-Hermes-Session-Key": "webui:owner",
"Authorization": "Bearer sk-secret",
}
other_headers = {
"X-Hermes-Session-Key": "webui:other",
"Authorization": "Bearer sk-secret",
}
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
mock_result,
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
create_resp = await cli.post(
"/v1/responses",
headers=owner_headers,
json={"model": "hermes-agent", "input": "secret"},
)
assert create_resp.status == 200
response_id = (await create_resp.json())["id"]

get_resp = await cli.get(f"/v1/responses/{response_id}", headers=other_headers)
assert get_resp.status == 404

chain_resp = await cli.post(
"/v1/responses",
headers=other_headers,
json={
"model": "hermes-agent",
"input": "continue",
"previous_response_id": response_id,
},
)
assert chain_resp.status == 404

delete_resp = await cli.delete(f"/v1/responses/{response_id}", headers=other_headers)
assert delete_resp.status == 404

owner_get_resp = await cli.get(f"/v1/responses/{response_id}", headers=owner_headers)
assert owner_get_resp.status == 200

owner_delete_resp = await cli.delete(f"/v1/responses/{response_id}", headers=owner_headers)
assert owner_delete_resp.status == 200

@pytest.mark.asyncio
async def test_runs_previous_response_id_is_scoped_to_session_key(self, auth_adapter):
"""Runs API previous_response_id chaining cannot cross X-Hermes-Session-Key owners."""
response_id = "resp_owner"
auth_adapter._response_store.put(
response_id,
{
"response": {"id": response_id},
"conversation_history": [{"role": "user", "content": "private context"}],
"instructions": "private instructions",
"session_id": "owner-session",
"gateway_session_key": "webui:owner",
},
)

app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_create_agent") as mock_create_agent:
resp = await cli.post(
"/v1/runs",
headers={
"X-Hermes-Session-Key": "webui:other",
"Authorization": "Bearer sk-secret",
},
json={
"model": "hermes-agent",
"input": "continue",
"previous_response_id": response_id,
},
)

assert resp.status == 404
mock_create_agent.assert_not_called()

@pytest.mark.asyncio
async def test_conversation_names_are_not_reused_across_session_keys(self, auth_adapter):
"""A conversation name owned by one session key starts fresh for another key."""
app = _create_app(auth_adapter)
async with TestClient(TestServer(app)) as cli:
with patch.object(auth_adapter, "_run_agent", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (
{"final_response": "first", "messages": [], "api_calls": 1},
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
first_resp = await cli.post(
"/v1/responses",
headers={
"X-Hermes-Session-Key": "webui:owner",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "input": "hello", "conversation": "shared-name"},
)
assert first_resp.status == 200

mock_run.return_value = (
{"final_response": "second", "messages": [], "api_calls": 1},
{"input_tokens": 0, "output_tokens": 0, "total_tokens": 0},
)
second_resp = await cli.post(
"/v1/responses",
headers={
"X-Hermes-Session-Key": "webui:other",
"Authorization": "Bearer sk-secret",
},
json={"model": "hermes-agent", "input": "hello again", "conversation": "shared-name"},
)
assert second_resp.status == 200
assert mock_run.call_args.kwargs["conversation_history"] == []

@pytest.mark.asyncio
async def test_capabilities_advertises_session_key_header(self, adapter):
"""GET /v1/capabilities should advertise the new header so clients can feature-detect."""
Expand Down
4 changes: 2 additions & 2 deletions tests/hermes_cli/test_model_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ def test_probe_sends_client_context_to_gemini(self):

body = b'{"data":[]}'
with patch(
"hermes_cli.models.urllib.request.urlopen",
"hermes_cli.models._urlopen_model_catalog_request",
return_value=self._make_mock_response(body),
) as mock_urlopen:
probe_api_models(
Expand All @@ -965,7 +965,7 @@ def test_probe_omits_gemini_client_context_for_other_providers(self):

body = b'{"data":[]}'
with patch(
"hermes_cli.models.urllib.request.urlopen",
"hermes_cli.models._urlopen_model_catalog_request",
return_value=self._make_mock_response(body),
) as mock_urlopen:
probe_api_models("provider-key", "https://api.example.com/v1")
Expand Down
Loading