mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
* fix(proxy): check previous_response_id ownership on /v1/responses/compact * test(proxy): cover the unmanaged response id escape hatches on every response id route * test(proxy): audit compact previous_response_id ownership Add integration cells for the native /v1/responses/compact routes covering owner, admin, cross-tenant, unmanaged, malformed, duplicated and retained ids, provider errors, the response cache, id-security settings, an upstream outage, a held upstream, a worker kill and a proxy restart. Unskip the provider wire cell whose bug this branch fixes. * test(proxy): cover the no signing key path on every response id route
498 lines
22 KiB
Python
498 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Final, Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
import openai
|
|
import pytest
|
|
from integration._support.client import Gateway, Scenario, object_value
|
|
from integration._support.process import owned_proxy
|
|
from integration._support.responses_vendor import same_response
|
|
from integration._support.wire import Reply, Request, Wire, wire_server
|
|
from integration._support.worker_readiness import wait_for_worker_readiness
|
|
from openai.types.responses import ResponseInputParam
|
|
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter
|
|
|
|
_MODEL: Final = "gpt-5"
|
|
_API_KEY: Final = "synthetic-responses-key"
|
|
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
|
_PROXY_WORKERS: Final = max(1, int(os.environ.get("INTEGRATION_PROXY_WORKERS", "1")))
|
|
_ALIASES: Final = ("/v1", "", "/openai/v1")
|
|
_DISABLE_HINT: Final = (
|
|
"To disable this security feature, set general_settings::disable_responses_id_security to True "
|
|
"in the config.yaml file."
|
|
)
|
|
_OTHER_USER_DETAIL: Final = (
|
|
f"Forbidden. The response id is not associated with the user, who this key belongs to. {_DISABLE_HINT}"
|
|
)
|
|
_OTHER_TEAM_DETAIL: Final = (
|
|
f"Forbidden. The response id is not associated with the team, who this key belongs to. {_DISABLE_HINT}"
|
|
)
|
|
_UNMANAGED_DETAIL: Final = (
|
|
"Forbidden. This response id was not issued by this proxy, so the proxy cannot tell who owns it. "
|
|
"To let keys address responses this proxy did not issue, set "
|
|
"general_settings::allow_unmanaged_response_ids to True in the config.yaml file."
|
|
)
|
|
|
|
|
|
class _DeleteResponse(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
id: str
|
|
object: Literal["response"]
|
|
deleted: bool
|
|
|
|
|
|
def _deployments_in_group(gateway: Gateway, group: str) -> int:
|
|
entries: Final = gateway.get("/model/info")["data"]
|
|
assert isinstance(entries, list), entries
|
|
return sum(object_value(entry).get("model_name") == group for entry in entries)
|
|
|
|
|
|
def _wait_for_workers(gateway: Gateway, group: str, deployments: int) -> None:
|
|
wait_for_worker_readiness(gateway, group, deployments, _PROXY_WORKERS)
|
|
|
|
|
|
def _model_discovery_reply() -> Reply:
|
|
return Reply(
|
|
body=json.dumps(
|
|
{"object": "list", "data": [{"id": _MODEL, "object": "model", "created": 1, "owned_by": "openai"}]}
|
|
).encode()
|
|
)
|
|
|
|
|
|
def _contract_requests(wire: Wire) -> tuple[Request, ...]:
|
|
requests: Final = wire.drain()
|
|
discovery: Final = tuple(request for request in requests if request.target == "/v1/models")
|
|
assert all(request.method == "GET" and request.body == b"" for request in discovery), requests
|
|
return tuple(request for request in requests if request.target != "/v1/models")
|
|
|
|
|
|
def _response(identity: str, status: str = "completed") -> bytes:
|
|
return json.dumps(
|
|
{
|
|
"id": identity,
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": status,
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}
|
|
).encode()
|
|
|
|
|
|
def _deployment(scenario: Scenario, wire: Wire) -> str:
|
|
model: Final = scenario.model(
|
|
model=f"openai/{_MODEL}",
|
|
api_key=_API_KEY,
|
|
api_base=f"{wire.url}/v1",
|
|
)
|
|
_wait_for_workers(scenario.gateway, model, 1)
|
|
return model
|
|
|
|
|
|
def _key(scenario: Scenario, team_id: str, user_id: str, model: str) -> str:
|
|
return scenario.key(team_id=team_id, user_id=user_id, models=[model])
|
|
|
|
|
|
def _client(gateway: Gateway, key: str, prefix: str) -> openai.OpenAI:
|
|
return openai.OpenAI(base_url=f"{gateway.client.base_url}{prefix}", api_key=key, max_retries=0)
|
|
|
|
|
|
def _error(response: httpx.Response, detail: str) -> None:
|
|
assert response.status_code == 403, response.text
|
|
assert response.json() == {
|
|
"error": {
|
|
"message": detail,
|
|
"type": "permission_error",
|
|
"param": None,
|
|
"code": "403",
|
|
}
|
|
}, response.text
|
|
|
|
|
|
def _refused(call: Callable[[], object], detail: str) -> None:
|
|
with pytest.raises(openai.PermissionDeniedError) as refusal:
|
|
call()
|
|
_error(refusal.value.response, detail)
|
|
|
|
|
|
def _every_id_route(client: openai.OpenAI, model: str, response_id: str) -> tuple[Callable[[], object], ...]:
|
|
return (
|
|
lambda: client.responses.retrieve(response_id),
|
|
lambda: client.responses.delete(response_id),
|
|
lambda: client.responses.cancel(response_id),
|
|
lambda: client.responses.input_items.list(response_id),
|
|
lambda: client.responses.create(model=model, input="cross-tenant follow-up", previous_response_id=response_id),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", _ALIASES)
|
|
def test_response_ids_are_scoped_to_the_issuing_user_and_team(gateway: Gateway, prefix: str) -> None:
|
|
raw_id: Final = f"resp_owned_{uuid.uuid4().hex}"
|
|
|
|
def respond(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target == "/v1/models":
|
|
return _model_discovery_reply()
|
|
body: Final = _JSON_OBJECT.validate_json(request.body) if request.body else {}
|
|
if request.method == "POST" and request.target == "/v1/responses":
|
|
if body.get("input") == "create owned response":
|
|
return Reply(body=_response(raw_id))
|
|
return Reply(body=_response(raw_id))
|
|
if request.method == "GET" and urlsplit(request.target).path.endswith("/input_items"):
|
|
return Reply(body=b'{"data":[],"has_more":false,"object":"list"}')
|
|
if request.method == "GET":
|
|
return Reply(body=_response(raw_id))
|
|
if request.method == "DELETE":
|
|
return Reply(body=json.dumps({"id": raw_id, "object": "response", "deleted": True}).encode())
|
|
if request.method == "POST" and request.target.endswith("/cancel"):
|
|
return Reply(body=_response(raw_id, "cancelled"))
|
|
raise AssertionError(f"Unexpected upstream request: {request.method} {request.target}")
|
|
|
|
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
|
model: Final = _deployment(scenario, wire)
|
|
team_a: Final = scenario.team(models=[model])
|
|
team_b: Final = scenario.team(models=[model])
|
|
user_a: Final = scenario.member(team_a)
|
|
user_b: Final = scenario.member(team_b)
|
|
gateway.post("/team/member_add", {"team_id": team_b, "member": {"role": "user", "user_id": user_a}})
|
|
client_a: Final = _client(gateway, _key(scenario, team_a, user_a, model), prefix)
|
|
other_user_client: Final = _client(gateway, _key(scenario, team_b, user_b, model), prefix)
|
|
other_team_client: Final = _client(gateway, _key(scenario, team_b, user_a, model), prefix)
|
|
created: Final = client_a.responses.create(model=model, input="create owned response")
|
|
client_id: Final = created.id
|
|
assert client_id.startswith("resp_") and client_id != raw_id, created.model_dump_json()
|
|
created_requests: Final = _contract_requests(wire)
|
|
assert [(request.method, request.target, request.body) for request in created_requests] == [
|
|
("POST", "/v1/responses", created_requests[0].body)
|
|
], created_requests
|
|
assert _JSON_OBJECT.validate_json(created_requests[0].body) == {
|
|
"model": _MODEL,
|
|
"input": "create owned response",
|
|
}, created_requests[0].body
|
|
|
|
for call in _every_id_route(other_user_client, model, client_id):
|
|
_refused(call, _OTHER_USER_DETAIL)
|
|
for call in _every_id_route(other_team_client, model, client_id):
|
|
_refused(call, _OTHER_TEAM_DETAIL)
|
|
assert _contract_requests(wire) == ()
|
|
|
|
retrieved_response: Final = client_a.responses.with_raw_response.retrieve(client_id)
|
|
retrieved: Final = retrieved_response.parse()
|
|
assert retrieved.id != raw_id and same_response(retrieved.id, client_id), retrieved_response.text
|
|
assert retrieved.model_dump(mode="json", exclude_none=True) == {
|
|
"id": retrieved.id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "completed",
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, retrieved_response.text
|
|
deleted: Final = client_a.responses.with_raw_response.delete(client_id)
|
|
assert deleted.status_code == 200, deleted.text
|
|
deleted_response: Final = _DeleteResponse.model_validate_json(deleted.content)
|
|
assert deleted_response.model_dump(exclude={"id"}) == {"object": "response", "deleted": True}, deleted.text
|
|
assert same_response(deleted_response.id, client_id), deleted.text
|
|
cancelled_response: Final = client_a.responses.with_raw_response.cancel(client_id)
|
|
cancelled: Final = cancelled_response.parse()
|
|
assert cancelled.status == "cancelled", cancelled_response.text
|
|
assert cancelled.id != raw_id and same_response(cancelled.id, client_id), cancelled_response.text
|
|
assert cancelled.model_dump(mode="json", exclude_none=True) == {
|
|
"id": cancelled.id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "cancelled",
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, cancelled_response.text
|
|
page_response: Final = client_a.responses.input_items.with_raw_response.list(client_id)
|
|
page: Final = page_response.parse()
|
|
assert page.model_dump(mode="json", exclude_none=True) == {
|
|
"data": [],
|
|
"has_more": False,
|
|
"object": "list",
|
|
}, page_response.text
|
|
followed: Final = client_a.responses.create(
|
|
model=model,
|
|
input="owned follow-up",
|
|
previous_response_id=client_id,
|
|
)
|
|
assert followed.id != raw_id and same_response(followed.id, client_id), followed.model_dump_json()
|
|
requests: Final = _contract_requests(wire)
|
|
assert [(request.method, urlsplit(request.target).path, request.body) for request in requests] == [
|
|
("GET", f"/v1/responses/{raw_id}", b""),
|
|
("DELETE", f"/v1/responses/{raw_id}", b""),
|
|
("POST", f"/v1/responses/{raw_id}/cancel", b"{}"),
|
|
("GET", f"/v1/responses/{raw_id}/input_items", b""),
|
|
("POST", "/v1/responses", requests[-1].body),
|
|
], requests
|
|
assert _JSON_OBJECT.validate_json(requests[-1].body) == {
|
|
"input": "owned follow-up",
|
|
"model": _MODEL,
|
|
"previous_response_id": raw_id,
|
|
}, requests[-1].body
|
|
|
|
unmanaged_id: Final = f"resp_vendor_{uuid.uuid4().hex}"
|
|
for call in _every_id_route(client_a, model, unmanaged_id):
|
|
_refused(call, _UNMANAGED_DETAIL)
|
|
assert _contract_requests(wire) == ()
|
|
|
|
|
|
@pytest.mark.parametrize("prefix", _ALIASES)
|
|
def test_compact_previous_response_id_is_scoped_to_the_issuing_user_and_team(
|
|
gateway: Gateway,
|
|
prefix: str,
|
|
) -> None:
|
|
raw_id: Final = f"resp_compact_owned_{uuid.uuid4().hex}"
|
|
compact_input: Final[ResponseInputParam] = [
|
|
{"role": "user", "content": [{"type": "input_text", "text": "compact the owned response"}]}
|
|
]
|
|
|
|
def respond(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target == "/v1/models":
|
|
return _model_discovery_reply()
|
|
if request.method == "POST" and request.target == "/v1/responses":
|
|
return Reply(body=_response(raw_id))
|
|
return Reply(body=_response(raw_id))
|
|
|
|
with wire_server(respond) as wire, gateway.scenario() as scenario:
|
|
model: Final = _deployment(scenario, wire)
|
|
team_a: Final = scenario.team(models=[model])
|
|
team_b: Final = scenario.team(models=[model])
|
|
user_a: Final = scenario.member(team_a)
|
|
user_b: Final = scenario.member(team_b)
|
|
gateway.post("/team/member_add", {"team_id": team_b, "member": {"role": "user", "user_id": user_a}})
|
|
client_a: Final = _client(gateway, _key(scenario, team_a, user_a, model), prefix)
|
|
other_user_client: Final = _client(gateway, _key(scenario, team_b, user_b, model), prefix)
|
|
other_team_client: Final = _client(gateway, _key(scenario, team_b, user_a, model), prefix)
|
|
created: Final = client_a.responses.create(model=model, input="create compact response")
|
|
client_id: Final = created.id
|
|
assert client_id != raw_id, created.model_dump_json()
|
|
assert len(_contract_requests(wire)) == 1
|
|
_refused(
|
|
lambda: other_user_client.responses.compact(
|
|
model=model,
|
|
previous_response_id=client_id,
|
|
input=[
|
|
{"role": "user", "content": [{"type": "input_text", "text": "compact someone else's response"}]}
|
|
],
|
|
),
|
|
_OTHER_USER_DETAIL,
|
|
)
|
|
_refused(
|
|
lambda: other_team_client.responses.compact(
|
|
model=model,
|
|
previous_response_id=client_id,
|
|
input=[
|
|
{"role": "user", "content": [{"type": "input_text", "text": "compact someone else's response"}]}
|
|
],
|
|
),
|
|
_OTHER_TEAM_DETAIL,
|
|
)
|
|
unmanaged_id: Final = f"resp_vendor_{uuid.uuid4().hex}"
|
|
_refused(
|
|
lambda: client_a.responses.compact(
|
|
model=model,
|
|
previous_response_id=unmanaged_id,
|
|
input=[
|
|
{"role": "user", "content": [{"type": "input_text", "text": "compact someone else's response"}]}
|
|
],
|
|
),
|
|
_UNMANAGED_DETAIL,
|
|
)
|
|
assert _contract_requests(wire) == ()
|
|
|
|
client_a.responses.compact(model=model, previous_response_id=client_id, input=compact_input)
|
|
compacted_requests: Final = _contract_requests(wire)
|
|
assert [(request.method, request.target) for request in compacted_requests] == [
|
|
("POST", "/v1/responses/compact")
|
|
], compacted_requests
|
|
assert _JSON_OBJECT.validate_json(compacted_requests[0].body) == {
|
|
"model": _MODEL,
|
|
"input": compact_input,
|
|
"previous_response_id": raw_id,
|
|
}, compacted_requests[0].body
|
|
|
|
|
|
def test_allow_unmanaged_response_ids_forward_the_vendor_id_on_create(
|
|
gateway: Gateway,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
raw_id: Final = f"resp_vendor_{uuid.uuid4().hex}"
|
|
raw_followup_id: Final = f"resp_followup_{uuid.uuid4().hex}"
|
|
|
|
def respond(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target == "/v1/models":
|
|
return _model_discovery_reply()
|
|
return Reply(body=_response(raw_followup_id))
|
|
|
|
config: Final = tmp_path / "allow_unmanaged_responses.yaml"
|
|
config.write_text("general_settings:\n allow_unmanaged_response_ids: true\n")
|
|
with wire_server(respond) as wire, wire_server(respond) as decoy, gateway.scenario() as scenario:
|
|
model: Final = _deployment(scenario, wire)
|
|
team: Final = scenario.team(models=[model])
|
|
user: Final = scenario.member(team)
|
|
key: Final = _key(scenario, team, user, model)
|
|
with owned_proxy(
|
|
gateway,
|
|
tmp_path,
|
|
{
|
|
"INTEGRATION_PROXY_WORKERS": "1",
|
|
"OPENAI_BASE_URL": f"{decoy.url}/v1",
|
|
"OPENAI_API_KEY": "synthetic-env-fallback-key",
|
|
},
|
|
config=config,
|
|
) as allowed_gateway:
|
|
client: Final = _client(allowed_gateway, key, "/v1")
|
|
followed: Final = client.responses.create(
|
|
model=model,
|
|
input="allowed vendor follow-up",
|
|
previous_response_id=raw_id,
|
|
)
|
|
assert followed.model_dump(mode="json", exclude_none=True) == {
|
|
"id": followed.id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "completed",
|
|
"model": model,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, followed.model_dump_json()
|
|
retrieved_response: Final = client.responses.with_raw_response.retrieve(followed.id)
|
|
retrieved: Final = retrieved_response.parse()
|
|
assert same_response(retrieved.id, followed.id), retrieved_response.text
|
|
assert retrieved.model_dump(mode="json", exclude_none=True) == {
|
|
"id": retrieved.id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "completed",
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, retrieved_response.text
|
|
requests: Final = _contract_requests(wire)
|
|
assert decoy.drain() == (), "The environment fallback deployment received a request"
|
|
assert [(request.method, request.target, request.body) for request in requests] == [
|
|
("POST", "/v1/responses", requests[0].body),
|
|
("GET", f"/v1/responses/{raw_followup_id}", b""),
|
|
], requests
|
|
assert all(request.headers["authorization"] == f"Bearer {_API_KEY}" for request in requests), requests
|
|
assert _JSON_OBJECT.validate_json(requests[0].body) == {
|
|
"model": _MODEL,
|
|
"input": "allowed vendor follow-up",
|
|
"previous_response_id": raw_id,
|
|
}, requests[0].body
|
|
|
|
|
|
@pytest.mark.parametrize("route", ("retrieve", "delete", "cancel", "input_items"))
|
|
def test_allow_unmanaged_response_id_lifecycle_route_reaches_provider(
|
|
gateway: Gateway,
|
|
tmp_path: Path,
|
|
route: Literal["retrieve", "delete", "cancel", "input_items"],
|
|
) -> None:
|
|
pytest.skip(
|
|
"BUG: unmanaged response-id lifecycle routes select model=None and return 400 instead of reaching OpenAI"
|
|
)
|
|
raw_id: Final = f"resp_vendor_{uuid.uuid4().hex}"
|
|
|
|
def respond(request: Request) -> Reply:
|
|
if request.method == "GET" and request.target == "/v1/models":
|
|
return _model_discovery_reply()
|
|
if request.method == "GET" and request.target == f"/v1/responses/{raw_id}/input_items":
|
|
return Reply(body=b'{"data":[],"has_more":false,"object":"list"}')
|
|
if request.method == "DELETE":
|
|
return Reply(body=json.dumps({"id": raw_id, "object": "response", "deleted": True}).encode())
|
|
if request.method == "POST" and request.target.endswith("/cancel"):
|
|
return Reply(body=_response(raw_id, "cancelled"))
|
|
return Reply(body=_response(raw_id))
|
|
|
|
config: Final = tmp_path / "allow_unmanaged_responses.yaml"
|
|
config.write_text("general_settings:\n allow_unmanaged_response_ids: true\n")
|
|
with wire_server(respond) as wire, wire_server(respond) as decoy, gateway.scenario() as scenario:
|
|
model: Final = _deployment(scenario, wire)
|
|
team: Final = scenario.team(models=[model])
|
|
user: Final = scenario.member(team)
|
|
key: Final = _key(scenario, team, user, model)
|
|
with owned_proxy(
|
|
gateway,
|
|
tmp_path,
|
|
{
|
|
"INTEGRATION_PROXY_WORKERS": "1",
|
|
"OPENAI_BASE_URL": f"{decoy.url}/v1",
|
|
"OPENAI_API_KEY": "synthetic-env-fallback-key",
|
|
},
|
|
config=config,
|
|
) as allowed_gateway:
|
|
client: Final = _client(allowed_gateway, key, "/v1")
|
|
expected_request: Final = {
|
|
"retrieve": ("GET", f"/v1/responses/{raw_id}", b""),
|
|
"delete": ("DELETE", f"/v1/responses/{raw_id}", b""),
|
|
"cancel": ("POST", f"/v1/responses/{raw_id}/cancel", b"{}"),
|
|
"input_items": ("GET", f"/v1/responses/{raw_id}/input_items", b""),
|
|
}[route]
|
|
match route:
|
|
case "retrieve":
|
|
retrieved: Final = client.responses.retrieve(raw_id)
|
|
assert retrieved.model_dump(mode="json", exclude_none=True) == {
|
|
"id": raw_id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "completed",
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, retrieved.model_dump_json()
|
|
case "delete":
|
|
deleted: Final = client.responses.delete(raw_id)
|
|
assert deleted.model_dump(mode="json", exclude_none=True) == {
|
|
"id": raw_id,
|
|
"object": "response",
|
|
"deleted": True,
|
|
}, deleted.model_dump_json()
|
|
case "cancel":
|
|
cancelled: Final = client.responses.cancel(raw_id)
|
|
assert cancelled.model_dump(mode="json", exclude_none=True) == {
|
|
"id": raw_id,
|
|
"object": "response",
|
|
"created_at": 1.0,
|
|
"status": "cancelled",
|
|
"model": _MODEL,
|
|
"output": [],
|
|
"parallel_tool_calls": False,
|
|
"tool_choice": "auto",
|
|
"tools": [],
|
|
}, cancelled.model_dump_json()
|
|
case "input_items":
|
|
page: Final = client.responses.input_items.list(raw_id)
|
|
assert page.model_dump(mode="json", exclude_none=True) == {
|
|
"data": [],
|
|
"has_more": False,
|
|
"object": "list",
|
|
}, page.model_dump_json()
|
|
requests: Final = _contract_requests(wire)
|
|
assert tuple((request.method, request.target, request.body) for request in requests) == (expected_request,), (
|
|
requests
|
|
)
|
|
assert all(request.headers["authorization"] == f"Bearer {_API_KEY}" for request in requests), requests
|
|
assert decoy.drain() == (), "The environment fallback deployment received a request"
|