From 5261a2cc06320a9ff513b86adff3e7949fb3c7f9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 06:59:15 +0000 Subject: [PATCH] test(integration): cover responses endpoint contracts (#44733) * test(integration): cover responses endpoint contracts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): tighten responses contract tests per review Isolate team-only response-id ownership with a same-user different-team key, send every denial through the OpenAI SDK on all three aliases, make the previous_response_id affinity check deterministic, assert the whole cancel response, prove the Cursor stream text, -fast suffixes and include_obfuscation strip, add SDK compact cases, restore router pre-call checks after the affinity test, and inline the root-level responses_test_support helpers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): wait for every proxy worker before routing to new deployments Restore the cross-worker convergence wait dropped when the shared helper was inlined, so the affinity and retrieve-routing tests cannot pass before the alternative deployment is loaded on a multi-worker gateway Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): cover Responses ownership and wire contracts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): address responses contract review Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): expect the provider model in the conversation passthrough body Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): expand responses endpoint contracts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): restore streamed response retrieval Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): wait for proxy workers after deployment setup Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): track readiness per proxy worker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: mateo Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../integration/_support/worker_readiness.py | 64 ++ .../test_responses_id_ownership.py | 487 +++++++++++++++ .../providers/test_cursor_responses_wire.py | 450 ++++++++++++++ .../providers/test_responses_compact_wire.py | 367 +++++++++++ .../providers/test_responses_core_wire.py | 406 ++++++++++++ .../test_responses_lifecycle_wire.py | 583 ++++++++++++++++++ 6 files changed, 2357 insertions(+) create mode 100644 tests/integration/_support/worker_readiness.py create mode 100644 tests/integration/authorization/test_responses_id_ownership.py create mode 100644 tests/integration/providers/test_cursor_responses_wire.py create mode 100644 tests/integration/providers/test_responses_compact_wire.py create mode 100644 tests/integration/providers/test_responses_core_wire.py create mode 100644 tests/integration/providers/test_responses_lifecycle_wire.py diff --git a/tests/integration/_support/worker_readiness.py b/tests/integration/_support/worker_readiness.py new file mode 100644 index 00000000000..ba11d2ba4bf --- /dev/null +++ b/tests/integration/_support/worker_readiness.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import time +from collections.abc import Iterator, Mapping +from itertools import accumulate +from types import MappingProxyType +from typing import Final + +import httpx + +from integration._support.client import Gateway, JSON_OBJECT, object_value +from litellm.constants import PROXY_CONFIG_RELOAD_INTERVAL_SECONDS + + +def _record_latest_readback( + latest_by_worker: Mapping[int, int], + observation: tuple[int, int], +) -> Mapping[int, int]: + worker_pid, count = observation + return MappingProxyType({**latest_by_worker, worker_pid: count}) + + +def wait_for_worker_readiness( + gateway: Gateway, + group: str, + deployments: int, + workers: int, +) -> None: + deadline: Final = time.monotonic() + (30 if workers == 1 else PROXY_CONFIG_RELOAD_INTERVAL_SECONDS * 2 + 30) + headers: Final = {"Authorization": f"Bearer {gateway.key}"} + + def readbacks() -> Iterator[tuple[int, int]]: + while time.monotonic() < deadline: + timeout: Final = deadline - time.monotonic() + with httpx.Client( + base_url=str(gateway.client.base_url), + timeout=timeout, + trust_env=False, + verify=False, + ) as client: + debug_response: Final = client.get("/debug/memory/summary", headers=headers) + response: Final = client.get( + "/model/info", + headers=headers, + ) + assert debug_response.status_code == 200, debug_response.text + assert response.status_code == 200, response.text + assert debug_response.extensions.get("network_stream") is not None + assert debug_response.extensions.get("network_stream") is response.extensions.get("network_stream") + debug_body: Final = JSON_OBJECT.validate_json(debug_response.content) + worker_pid: Final = debug_body.get("worker_pid") + assert isinstance(worker_pid, int), debug_body + body: Final = JSON_OBJECT.validate_json(response.content) + entries: Final = body.get("data") + assert isinstance(entries, list), body + count: Final = sum(object_value(entry).get("model_name") == group for entry in entries) + yield worker_pid, count + + initial_counts: Final[Mapping[int, int]] = MappingProxyType({}) + latest_counts: Final = accumulate(readbacks(), _record_latest_readback, initial=initial_counts) + for latest_by_worker in latest_counts: + if len(latest_by_worker) >= workers and all(count == deployments for count in latest_by_worker.values()): + return + raise AssertionError(f"Expected {deployments} deployments on {workers} workers before the readiness deadline") diff --git a/tests/integration/authorization/test_responses_id_ownership.py b/tests/integration/authorization/test_responses_id_ownership.py new file mode 100644 index 00000000000..9e13b45e385 --- /dev/null +++ b/tests/integration/authorization/test_responses_id_ownership.py @@ -0,0 +1,487 @@ +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 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: + pytest.skip( + "BUG: POST /v1/responses/compact does not check previous_response_id ownership; " + "another team's id and unmanaged ids reach the provider" + ) + raw_id: Final = f"resp_compact_owned_{uuid.uuid4().hex}" + + 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) == () + + +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" diff --git a/tests/integration/providers/test_cursor_responses_wire.py b/tests/integration/providers/test_cursor_responses_wire.py new file mode 100644 index 00000000000..cf766c9105a --- /dev/null +++ b/tests/integration/providers/test_cursor_responses_wire.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import json +import os +import uuid +from typing import Final + +import pytest +from integration._support.client import Gateway, Scenario +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 pydantic import JsonValue, TypeAdapter + +_MODEL: Final = "gpt-5" +_API_KEY: Final = "synthetic-cursor-key" +_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue]) +_PROXY_WORKERS: Final = max(1, int(os.environ.get("INTEGRATION_PROXY_WORKERS", "1"))) +_THINKING_LEVELS: Final = ("none", "minimal", "low", "medium", "high", "xhigh", "max") +_CHAT_THINKING_LEVELS: Final = ("none", "minimal", "low", "medium", "high", "max") +_RESPONSES_SUFFIXES: Final[tuple[tuple[str, dict[str, JsonValue]], ...]] = tuple( + (f"-thinking-{level}", {"reasoning": {"effort": level}}) for level in _THINKING_LEVELS +) + ( + ("-fast", {}), + ("-thinking-low-fast", {"reasoning": {"effort": "low"}}), + ("-thinking-high-fast", {"reasoning": {"effort": "high"}}), +) +_CHAT_SUFFIXES: Final[tuple[tuple[str, str], ...]] = (("", ""),) + tuple( + (f"-thinking-{level}", level) for level in _CHAT_THINKING_LEVELS +) +_PROMPT: Final = "Inspect the requested change." +_INPUT: Final = [{"role": "user", "content": [{"type": "input_text", "text": _PROMPT}]}] +_CUSTOM_TOOL: Final = { + "type": "custom", + "name": "ApplyPatch", + "description": "Apply a patch", + "format": {"type": "grammar", "syntax": "lark", "definition": 'start: "ok"'}, +} +_FUNCTION_TOOL: Final = { + "type": "function", + "name": "lookup", + "description": "Look up a record", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, + "strict": True, +} + + +def model_discovery_reply(model: str) -> Reply: + return Reply( + body=json.dumps( + {"object": "list", "data": [{"id": model, "object": "model", "created": 1, "owned_by": "openai"}]} + ).encode() + ) + + +def drain_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) -> dict[str, JsonValue]: + return { + "id": identity, + "object": "response", + "created_at": 1, + "status": "completed", + "model": _MODEL, + "output": [ + { + "id": "msg_cursor", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "Patch inspected.", "annotations": []}], + } + ], + "usage": {"input_tokens": 4, "output_tokens": 3, "total_tokens": 7}, + } + + +def _stream(identity: str) -> tuple[bytes, ...]: + response: Final = _response(identity) + return tuple( + f"data: {json.dumps(event)}\n\n".encode() + for event in ( + {"type": "response.created", "response": {**response, "status": "in_progress", "output": []}}, + { + "type": "response.output_text.delta", + "item_id": "msg_cursor", + "output_index": 0, + "content_index": 0, + "delta": "Patch inspected.", + }, + {"type": "response.completed", "response": response}, + ) + ) + (b"data: [DONE]\n\n",) + + +def _chat_completion(stream: bool) -> bytes | tuple[bytes, ...]: + if stream: + return ( + b'data: {"id":"chat_cursor_1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{"content":"Patch inspected."},"finish_reason":null}]}\n\n', + b'data: {"id":"chat_cursor_1","object":"chat.completion.chunk","created":1,"model":"gpt-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n', + b"data: [DONE]\n\n", + ) + return json.dumps( + { + "id": "chat_cursor_1", + "object": "chat.completion", + "created": 1, + "model": _MODEL, + "system_fingerprint": "fp_cursor", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Patch inspected."}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7}, + } + ).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_worker_readiness(scenario.gateway, model, 1, _PROXY_WORKERS) + return model + + +@pytest.mark.parametrize( + ("suffix", "reasoning"), + _RESPONSES_SUFFIXES, +) +@pytest.mark.parametrize("stream", (False, True)) +def test_cursor_responses_input_uses_responses_wire_and_chat_output( + gateway: Gateway, + stream: bool, + suffix: str, + reasoning: dict[str, JsonValue], +) -> None: + raw_id: Final = f"resp_cursor_{uuid.uuid4().hex}" + with gateway.scenario() as scenario: + team_alias: Final = f"integration-{uuid.uuid4().hex}" + team: Final = scenario.team(team_alias=team_alias) + user: Final = scenario.member(team) + key: Final = scenario.key(team_id=team, user_id=user) + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.target == "/v1/chat/completions": + completion: Final = _chat_completion(stream) + return Reply( + content_type="text/event-stream" if stream else "application/json", + chunks=completion if isinstance(completion, tuple) else None, + body=completion if isinstance(completion, bytes) else b"", + ) + assert request.target == "/v1/responses", request.target + body: Final = _JSON_OBJECT.validate_json(request.body) + assert {key: value for key, value in body.items() if key != "metadata"} == { + "model": _MODEL, + "input": _INPUT, + **reasoning, + "tools": [_CUSTOM_TOOL], + "stream": stream, + }, request.body + if stream: + return Reply(content_type="text/event-stream", chunks=_stream(raw_id)) + return Reply(body=json.dumps(_response(raw_id)).encode()) + + with wire_server(respond) as wire: + model: Final = _deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/cursor/chat/completions", + { + "model": f"{model}{suffix}", + "input": _INPUT, + "messages": [], + "tools": [_CUSTOM_TOOL], + "stream_options": {"include_usage": True, "include_obfuscation": False}, + "stream": stream, + }, + key=key, + ) + assert response.status_code == 200, response.text + if stream: + events: Final = tuple( + _JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ) + assert events, response.text + assert all(event.get("object") == "chat.completion.chunk" for event in events), response.text + assert all(not str(event.get("type", "")).startswith("response.") for event in events), response.text + assert [{k: v for k, v in event.items() if k not in ("id", "created")} for event in events] == [ + { + "object": "chat.completion.chunk", + "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Patch inspected."}}], + }, + { + "object": "chat.completion.chunk", + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + }, + ], response.text + else: + payload: Final = _JSON_OBJECT.validate_json(response.content) + identity: Final = payload.get("id") + created: Final = payload.get("created") + assert isinstance(identity, str) and same_response(identity, raw_id), response.text + assert isinstance(created, int) and created > 0, response.text + assert payload == { + "id": identity, + "created": created, + "model": model, + "object": "chat.completion", + "system_fingerprint": None, + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Patch inspected.", + "role": "assistant", + "tool_calls": None, + "function_call": None, + "provider_specific_fields": None, + }, + } + ], + "usage": { + "completion_tokens": 3, + "prompt_tokens": 4, + "total_tokens": 7, + "completion_tokens_details": None, + "prompt_tokens_details": None, + }, + }, response.text + assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses"] + + +def test_cursor_responses_body_carries_no_proxy_metadata(gateway: Gateway) -> None: + pytest.skip( + "BUG: /cursor/chat/completions forwards proxy-internal metadata (key hash, team, user, client ip, user agent) " + "to the provider in the Responses body" + ) + raw_id: Final = f"resp_cursor_metadata_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" and request.target == "/v1/responses", request.target + return Reply(body=json.dumps(_response(raw_id)).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/cursor/chat/completions", + { + "model": f"{model}-thinking-high", + "input": _INPUT, + "messages": [], + "tools": [_CUSTOM_TOOL], + }, + ) + assert response.status_code == 200, response.text + requests: Final = drain_contract_requests(wire) + assert len(requests) == 1, requests + body: Final = _JSON_OBJECT.validate_json(requests[0].body) + assert body == { + "model": _MODEL, + "input": _INPUT, + "reasoning": {"effort": "high"}, + "tools": [_CUSTOM_TOOL], + }, requests[0].body + + +def test_cursor_stream_reports_usage_when_the_client_asks_for_it(gateway: Gateway) -> None: + pytest.skip( + "BUG: /cursor/chat/completions with stream_options.include_usage streams no usage chunk; " + "the Responses usage from response.completed never reaches the chat client" + ) + raw_id: Final = f"resp_cursor_usage_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert (request.method, request.target) == ("POST", "/v1/responses"), request.target + return Reply(content_type="text/event-stream", chunks=_stream(raw_id)) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + response: Final = gateway.request( + "POST", + "/cursor/chat/completions", + { + "model": model, + "input": _INPUT, + "stream_options": {"include_usage": True}, + "stream": True, + }, + ) + assert response.status_code == 200, response.text + events: Final = tuple( + _JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ) + assert [event["usage"] for event in events if event.get("usage")] == [ + {"completion_tokens": 3, "prompt_tokens": 4, "total_tokens": 7} + ], response.text + assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses"] + + +@pytest.mark.parametrize(("suffix", "reasoning_effort"), _CHAT_SUFFIXES) +@pytest.mark.parametrize("stream", (False, True)) +def test_cursor_messages_body_uses_chat_completions_wire( + gateway: Gateway, + stream: bool, + suffix: str, + reasoning_effort: str, +) -> None: + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + completion: Final = _chat_completion(stream) + return Reply( + content_type="text/event-stream" if stream else "application/json", + chunks=completion if isinstance(completion, tuple) else None, + body=completion if isinstance(completion, bytes) else b"", + ) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + team: Final = scenario.team(models=[model]) + user: Final = scenario.member(team) + key: Final = scenario.key(team_id=team, user_id=user, models=[model]) + response: Final = gateway.request( + "POST", + "/cursor/chat/completions", + { + "model": f"{model}{suffix}", + "messages": [{"role": "user", "content": "genuine chat request"}], + "tools": [_FUNCTION_TOOL, _CUSTOM_TOOL], + "tool_choice": {"type": "function", "name": "lookup"}, + "stream": stream, + }, + key=key, + ) + assert response.status_code == 200, response.text + if stream: + events: Final = tuple( + _JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in response.text.splitlines() + if line.startswith("data: ") and line != "data: [DONE]" + ) + assert [ + {key: value for key, value in event.items() if key not in ("id", "created")} for event in events + ] == [ + { + "object": "chat.completion.chunk", + "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "Patch inspected."}}], + }, + { + "object": "chat.completion.chunk", + "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + }, + ], response.text + else: + payload: Final = _JSON_OBJECT.validate_json(response.content) + identity: Final = payload.get("id") + created: Final = payload.get("created") + assert isinstance(identity, str) and same_response(identity, "chat_cursor_1"), response.text + assert isinstance(created, int) and created > 0, response.text + assert payload == { + "id": identity, + "created": created, + "model": model, + "object": "chat.completion", + "system_fingerprint": "fp_cursor", + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Patch inspected.", + "role": "assistant", + "provider_specific_fields": {"refusal": None}, + }, + "provider_specific_fields": {}, + } + ], + "usage": { + "completion_tokens": 3, + "prompt_tokens": 4, + "total_tokens": 7, + }, + }, response.text + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target) for request in requests] == [("POST", "/v1/chat/completions")], ( + requests + ) + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "messages": [{"role": "user", "content": "genuine chat request"}], + "tools": [ + { + "type": "function", + "function": { + "name": "lookup", + "description": "Look up a record", + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + "strict": True, + }, + }, + { + "type": "custom", + "custom": { + "name": "ApplyPatch", + "description": "Apply a patch", + "format": { + "type": "grammar", + "grammar": {"syntax": "lark", "definition": 'start: "ok"'}, + }, + }, + }, + ], + "tool_choice": {"type": "function", "function": {"name": "lookup"}}, + **({"stream": True} if stream else {}), + **({"stream_options": {"include_usage": True}} if stream else {}), + **({"reasoning_effort": reasoning_effort} if reasoning_effort else {}), + }, requests[0].body diff --git a/tests/integration/providers/test_responses_compact_wire.py b/tests/integration/providers/test_responses_compact_wire.py new file mode 100644 index 00000000000..bc1c1ba2a7a --- /dev/null +++ b/tests/integration/providers/test_responses_compact_wire.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import json +import os +import uuid +from typing import Final + +import openai +import pytest +from integration._support.client import Gateway, Scenario +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 CompactedResponse +from pydantic import 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/responses/compact", "/responses/compact", "/openai/v1/responses/compact") +_FUNCTION_TOOL: Final = { + "type": "function", + "name": "lookup", + "description": "Look up a record", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, + "strict": True, +} + + +def model_discovery_reply(model: str) -> Reply: + return Reply( + body=json.dumps( + {"object": "list", "data": [{"id": model, "object": "model", "created": 1, "owned_by": "openai"}]} + ).encode() + ) + + +def drain_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 _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_worker_readiness(scenario.gateway, model, 1, _PROXY_WORKERS) + return model + + +def test_responses_compact_routes_forward_complete_request_and_typed_items(gateway: Gateway) -> None: + reasoning_item: Final = { + "id": f"rs_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Prior reasoning"}], + "encrypted_content": "synthetic-encrypted-content", + } + function_output_item: Final = { + "type": "function_call_output", + "call_id": "call_previous", + "output": "The record is ready.", + } + requests: Final = ( + { + "input": "Compact this conversation.", + }, + { + "input": [reasoning_item, function_output_item], + "instructions": "Preserve the latest decision.", + "tools": [_FUNCTION_TOOL], + }, + ) + output_items: Final = [ + { + "id": f"rs_compact_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Compacted state"}], + "encrypted_content": "synthetic-compact-state", + }, + { + "id": "fc_output_compacted", + "type": "function_call_output", + "call_id": "call_previous", + "output": "The record is ready.", + "status": "completed", + }, + ] + completed_id: Final = f"resp_compact_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + body: Final = _JSON_OBJECT.validate_json(request.body) + compact_params: Final = ( + { + "model": _MODEL, + **requests[0], + } + if body.get("input") == requests[0]["input"] + else { + "model": _MODEL, + **requests[1], + } + ) + assert body == compact_params, request.body + return Reply( + body=json.dumps( + { + "id": completed_id, + "created_at": 1, + "object": "response.compaction", + "output": output_items, + "usage": { + "input_tokens": 7, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 3, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 10, + }, + } + ).encode() + ) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + for alias in _ALIASES: + for request_body in requests: + response: Final = gateway.request( + "POST", + alias, + {"model": model, **request_body}, + ) + assert response.status_code == 200, response.text + compacted: Final = CompactedResponse.model_validate_json(response.content) + assert compacted.id != completed_id, compacted.model_dump_json() + assert same_response(compacted.id, completed_id), compacted.model_dump_json() + assert compacted.model_dump(mode="json") == { + "id": compacted.id, + "created_at": 1, + "error": None, + "incomplete_details": None, + "instructions": None, + "metadata": None, + "model": model, + "object": "response.compaction", + "output": [ + { + "id": output_items[0]["id"], + "summary": [{"text": "Compacted state", "type": "summary_text"}], + "type": "reasoning", + "content": None, + "encrypted_content": "synthetic-compact-state", + "status": None, + }, + { + "id": "fc_output_compacted", + "call_id": "call_previous", + "output": "The record is ready.", + "status": "completed", + "type": "function_call_output", + "created_by": None, + }, + ], + "parallel_tool_calls": None, + "temperature": None, + "tool_choice": None, + "tools": None, + "top_p": None, + "max_output_tokens": None, + "previous_response_id": None, + "reasoning": None, + "status": None, + "text": None, + "truncation": None, + "usage": { + "input_tokens": 7, + "input_tokens_details": { + "audio_tokens": None, + "cached_tokens": 0, + "cached_tokens_details": None, + "image_tokens": None, + "text_tokens": None, + "video_tokens": None, + }, + "output_tokens": 3, + "output_tokens_details": { + "audio_tokens": None, + "reasoning_tokens": 0, + "text_tokens": None, + }, + "total_tokens": 10, + "cost": None, + }, + "user": None, + "store": None, + }, response.text + assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses/compact"] * 6 + + +@pytest.mark.parametrize("prefix", ("/v1", "", "/openai/v1")) +def test_sdk_compact_reaches_the_compact_endpoint_with_its_full_body(gateway: Gateway, prefix: str) -> None: + completed_id: Final = f"resp_compact_sdk_{uuid.uuid4().hex}" + input_items: Final = [ + {"role": "user", "content": [{"type": "input_text", "text": "Summarize the patch so far."}]}, + { + "id": f"rs_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Prior reasoning"}], + "encrypted_content": "synthetic-encrypted-content", + }, + {"type": "function_call_output", "call_id": "call_previous", "output": "The record is ready."}, + ] + output_item: Final = { + "id": f"rs_compact_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Compacted state"}], + "encrypted_content": "synthetic-compact-state", + } + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert (request.method, request.target) == ("POST", "/v1/responses/compact"), request.target + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + assert _JSON_OBJECT.validate_json(request.body) == { + "model": _MODEL, + "input": input_items, + "instructions": "Preserve the latest decision.", + }, request.body + return Reply( + body=json.dumps( + { + "id": completed_id, + "created_at": 1, + "object": "response.compaction", + "output": [output_item], + "usage": { + "input_tokens": 7, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 3, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 10, + }, + } + ).encode() + ) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = openai.OpenAI(base_url=f"{gateway.client.base_url}{prefix}", api_key=gateway.key, max_retries=0) + compacted: Final = client.responses.compact( + model=model, + input=input_items, + instructions="Preserve the latest decision.", + ) + assert compacted.id != completed_id and same_response(compacted.id, completed_id), compacted.model_dump_json() + assert compacted.model_dump(mode="json", exclude_none=True) == { + "id": compacted.id, + "created_at": 1, + "model": model, + "object": "response.compaction", + "output": [output_item], + "usage": { + "input_tokens": 7, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 3, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 10, + }, + }, compacted.model_dump_json() + assert [(request.method, request.target) for request in drain_contract_requests(wire)] == [ + ("POST", "/v1/responses/compact") + ] + + +@pytest.mark.parametrize("prefix", ("/v1", "", "/openai/v1")) +def test_compact_previous_response_id_reaches_the_provider_as_the_raw_id( + gateway: Gateway, + prefix: str, +) -> None: + pytest.skip("BUG: compact forwards the proxy-encrypted previous_response_id instead of the raw provider id") + raw_id: Final = f"resp_compact_prev_{uuid.uuid4().hex}" + completed_id: Final = f"resp_compact_result_{uuid.uuid4().hex}" + input_items: Final = [{"role": "user", "content": [{"type": "input_text", "text": "Summarize the patch so far."}]}] + output_item: Final = { + "id": f"rs_compact_{uuid.uuid4().hex}", + "type": "reasoning", + "summary": [{"type": "summary_text", "text": "Compacted patch"}], + "encrypted_content": "synthetic-compact-state", + } + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + if request.method == "POST" and request.target == "/v1/responses": + return Reply( + body=json.dumps( + { + "id": raw_id, + "object": "response", + "created_at": 1, + "status": "completed", + "model": _MODEL, + "output": [], + } + ).encode() + ) + return Reply( + body=json.dumps( + { + "id": completed_id, + "created_at": 1, + "object": "response.compaction", + "output": [output_item], + "usage": { + "input_tokens": 7, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": 3, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": 10, + }, + } + ).encode() + ) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = openai.OpenAI( + base_url=f"{gateway.client.base_url}{prefix}", + api_key=gateway.key, + max_retries=0, + ) + created: Final = client.responses.create(model=model, input="create before compact") + assert created.id != raw_id, created.model_dump_json() + compact_response: Final = client.responses.with_raw_response.compact( + model=model, + previous_response_id=created.id, + input=input_items, + ) + compacted: Final = compact_response.parse() + assert compacted.object == "response.compaction", compact_response.http_response.text + assert compacted.created_at == 1, compact_response.http_response.text + assert same_response(compacted.id, completed_id), compact_response.http_response.text + assert len(compacted.output) == 1, compact_response.http_response.text + assert compacted.output[0].type == "reasoning", compact_response.http_response.text + assert compacted.output[0].summary[0].text == "Compacted patch", compact_response.http_response.text + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target) for request in requests] == [ + ("POST", "/v1/responses"), + ("POST", "/v1/responses/compact"), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "create before compact", + }, requests[0].body + assert _JSON_OBJECT.validate_json(requests[1].body) == { + "model": _MODEL, + "previous_response_id": raw_id, + "input": input_items, + }, compact_response.http_response.text diff --git a/tests/integration/providers/test_responses_core_wire.py b/tests/integration/providers/test_responses_core_wire.py new file mode 100644 index 00000000000..791b2b8783f --- /dev/null +++ b/tests/integration/providers/test_responses_core_wire.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import json +import os +import uuid +from typing import Final + +import openai +import pytest +from integration._support.client import Gateway, Scenario, object_value, string_value +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 ResponseCompletedEvent +from pydantic import 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") + + +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(model: str) -> Reply: + return Reply( + body=json.dumps( + {"object": "list", "data": [{"id": model, "object": "model", "created": 1, "owned_by": "openai"}]} + ).encode() + ) + + +def drain_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": [], + } + ).encode() + + +def _stream(identity: str) -> tuple[bytes, ...]: + return tuple( + f"data: {json.dumps(event)}\n\n".encode() + for event in ( + {"type": "response.created", "response": json.loads(_response(identity, "in_progress"))}, + {"type": "response.completed", "response": json.loads(_response(identity))}, + ) + ) + (b"data: [DONE]\n\n",) + + +def _register_in_group(scenario: Scenario, wire: Wire, group: str, order: int | None = None) -> str: + existing: Final = _deployments_in_group(scenario.gateway, group) + created: Final = scenario.gateway.post( + "/model/new", + { + "model_name": group, + "litellm_params": { + "model": f"openai/{_MODEL}", + "api_key": _API_KEY, + "api_base": f"{wire.url}/v1", + **({} if order is None else {"order": order}), + }, + "model_info": {}, + }, + ) + model_id: Final = string_value(object_value(created["model_info"])["id"]) + scenario.cleanups.callback(scenario.delete_model, model_id) + _wait_for_workers(scenario.gateway, group, existing + 1) + return group + + +def _register(scenario: Scenario, wire: Wire, group: str | None = None) -> str: + if group is not None: + return _register_in_group(scenario, wire, group) + 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 _enable_responses_affinity(scenario: Scenario) -> None: + gateway: Final = scenario.gateway + settings: Final = object_value(gateway.get("/router/settings")["current_values"]) + current: Final = settings.get("optional_pre_call_checks") + original: Final = list(current) if isinstance(current, list) else [] + scenario.cleanups.callback( + gateway.post, + "/config/update", + {"router_settings": {"optional_pre_call_checks": original}}, + ) + gateway.post( + "/config/update", + {"router_settings": {"optional_pre_call_checks": [*original, "responses_api_deployment_check"]}}, + ) + + +@pytest.mark.parametrize("prefix", _ALIASES) +@pytest.mark.parametrize("stream", (False, True)) +def test_previous_response_id_uses_the_raw_id_and_affinity( + gateway: Gateway, + prefix: str, + stream: bool, +) -> None: + first_raw_id: Final = f"resp_turn_one_{uuid.uuid4().hex}" + second_raw_id: Final = f"resp_turn_two_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" + assert request.target == "/v1/responses" + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + body: Final = _JSON_OBJECT.validate_json(request.body) + if body["input"] == "turn one": + assert body == {"model": _MODEL, "input": "turn one"}, request.body + return Reply(body=_response(first_raw_id)) + if stream: + return Reply(content_type="text/event-stream", chunks=_stream(second_raw_id)) + return Reply(body=_response(second_raw_id)) + + def unused(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" + assert request.target == "/v1/responses" + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if stream: + return Reply(content_type="text/event-stream", chunks=_stream(second_raw_id)) + return Reply(body=_response(second_raw_id)) + + with wire_server(respond) as first_wire, wire_server(unused) as second_wire, gateway.scenario() as scenario: + model: Final = f"integration-{uuid.uuid4().hex}" + _enable_responses_affinity(scenario) + assert _register(scenario, first_wire, model) == model + client: Final = openai.OpenAI( + base_url=f"{gateway.client.base_url}{prefix}", + api_key=gateway.key, + max_retries=0, + ) + first: Final = client.responses.create(model=model, input="turn one") + assert first.id.startswith("resp_") and first.id != first_raw_id, first.model_dump_json() + _register_in_group(scenario, second_wire, model, order=1) + second_result: Final = client.responses.create( + model=model, + input="turn two", + previous_response_id=first.id, + stream=stream, + ) + if stream: + events: Final = list(second_result) + completed: Final = events[-1] + assert isinstance(completed, ResponseCompletedEvent), events + assert completed.response.id != second_raw_id, completed.model_dump_json() + assert completed.response.output == [], completed.model_dump_json() + else: + assert second_result.id != second_raw_id, second_result.model_dump_json() + assert second_result.output == [], second_result.model_dump_json() + assert drain_contract_requests(second_wire) == (), "previous_response_id did not preserve deployment affinity" + requests: Final = drain_contract_requests(first_wire) + assert len(requests) == 2, requests + assert [_JSON_OBJECT.validate_json(request.body) for request in requests] == [ + {"model": _MODEL, "input": "turn one"}, + { + "model": _MODEL, + "input": "turn two", + "previous_response_id": first_raw_id, + "stream": stream, + }, + ], [request.body for request in requests] + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_codex_responses_request_preserves_all_supported_fields(gateway: Gateway, prefix: str) -> None: + response_id: Final = f"resp_codex_{uuid.uuid4().hex}" + input_items: Final = [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Inspect the requested change."}], + }, + { + "type": "reasoning", + "id": f"rs_{uuid.uuid4().hex}", + "summary": [{"type": "summary_text", "text": "Plan the inspection"}], + "encrypted_content": f"gAAAA-codex-encrypted-{uuid.uuid4().hex}", + }, + { + "type": "function_call", + "call_id": "call_lookup", + "name": "lookup", + "arguments": '{"query": "diff"}', + }, + { + "type": "function_call_output", + "call_id": "call_lookup", + "output": '{"rows": 1}', + }, + { + "type": "custom_tool_call", + "call_id": "call_patch", + "name": "ApplyPatch", + "input": "*** Begin Patch\n*** End Patch", + }, + { + "type": "custom_tool_call_output", + "call_id": "call_patch", + "output": "Done", + }, + ] + function_tool: Final = { + "type": "function", + "name": "lookup", + "description": "Look up a record", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, + "strict": True, + } + custom_tool: Final = { + "type": "custom", + "name": "ApplyPatch", + "description": "Apply a patch", + "format": {"type": "grammar", "syntax": "lark", "definition": 'start: "ok"'}, + } + web_search_tool: Final = {"type": "web_search"} + expected_body: Final = { + "model": _MODEL, + "input": input_items, + "stream": True, + "include": ["reasoning.encrypted_content"], + "store": False, + "prompt_cache_key": "cache-codex-contract", + "reasoning": {"effort": "high", "summary": "auto"}, + "text": { + "verbosity": "low", + "format": { + "type": "json_schema", + "name": "patch_summary", + "strict": True, + "schema": { + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + "additionalProperties": False, + }, + }, + }, + "tools": [function_tool, custom_tool, web_search_tool], + "tool_choice": "auto", + "parallel_tool_calls": True, + "instructions": "Return concise structured output.", + } + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.method == "POST" + assert request.target == "/v1/responses" + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + _JSON_OBJECT.validate_json(request.body) + return Reply(content_type="text/event-stream", chunks=_stream(response_id)) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _register(scenario, wire) + client: Final = openai.OpenAI( + base_url=f"{gateway.client.base_url}{prefix}", + api_key=gateway.key, + max_retries=0, + ) + stream: Final = client.responses.create( + model=model, + input=input_items, + stream=True, + include=["reasoning.encrypted_content"], + store=False, + prompt_cache_key="cache-codex-contract", + reasoning={"effort": "high", "summary": "auto"}, + text={ + "verbosity": "low", + "format": { + "type": "json_schema", + "name": "patch_summary", + "strict": True, + "schema": { + "type": "object", + "properties": {"summary": {"type": "string"}}, + "required": ["summary"], + "additionalProperties": False, + }, + }, + }, + tools=[function_tool, custom_tool, web_search_tool], + tool_choice="auto", + parallel_tool_calls=True, + instructions="Return concise structured output.", + ) + events: Final = list(stream) + completed: Final = events[-1] + assert isinstance(completed, ResponseCompletedEvent), events + assert completed.response.status == "completed", completed.model_dump_json() + assert completed.response.output == [], completed.model_dump_json() + requests: Final = drain_contract_requests(wire) + assert len(requests) == 1, requests + assert _JSON_OBJECT.validate_json(requests[0].body) == expected_body, requests[0].body + assert [request.target for request in requests] == ["/v1/responses"] + + +@pytest.mark.parametrize("prefix", _ALIASES) +@pytest.mark.parametrize("case", ("mixed", "many")) +def test_responses_metadata_reaches_the_provider_as_typed( + gateway: Gateway, + prefix: str, + case: str, +) -> None: + metadata: Final = ( + { + "string_value": "keep this", + "integer_value": 7, + "boolean_value": True, + "nested_value": {"key": "drop this"}, + "second_string": "keep this too", + } + if case == "mixed" + else {f"key_{index:02d}": f"value_{index:02d}" for index in range(17)} + ) + expected_metadata: Final = dict( + tuple((key, value) for key, value in metadata.items() if isinstance(value, str))[:16] + ) + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert (request.method, request.target) == ("POST", "/v1/responses"), request.target + return Reply(body=_response(f"resp_metadata_{uuid.uuid4().hex}")) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _register(scenario, wire) + response: Final = gateway.request( + "POST", + f"{prefix}/responses", + { + "model": model, + "input": "metadata propagation", + "metadata": metadata, + }, + ) + assert response.status_code == 200, response.text + requests: Final = drain_contract_requests(wire) + assert len(requests) == 1, requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "metadata propagation", + "metadata": expected_metadata, + }, requests[0].body + + +def test_conversation_is_forwarded_as_a_separate_responses_parameter(gateway: Gateway) -> None: + pytest.skip("BUG: POST /v1/responses drops the conversation param; upstream receives the body without it") + conversation: Final = {"id": f"conv_{uuid.uuid4().hex}"} + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + return Reply(body=_response(f"resp_conversation_{uuid.uuid4().hex}")) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _register(scenario, wire) + client: Final = openai.OpenAI( + base_url=f"{gateway.client.base_url}/v1", + api_key=gateway.key, + max_retries=0, + ) + response: Final = client.responses.create( + model=model, + input="continue this conversation", + conversation=conversation, + ) + assert response.object == "response", response.model_dump_json() + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target) for request in requests] == [("POST", "/v1/responses")], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "continue this conversation", + "conversation": conversation, + }, requests[0].body diff --git a/tests/integration/providers/test_responses_lifecycle_wire.py b/tests/integration/providers/test_responses_lifecycle_wire.py new file mode 100644 index 00000000000..6509c8ced96 --- /dev/null +++ b/tests/integration/providers/test_responses_lifecycle_wire.py @@ -0,0 +1,583 @@ +from __future__ import annotations + +import json +import os +import uuid +from typing import Final, Literal +from urllib.parse import parse_qsl, urlsplit + +import openai +import pytest +from integration._support.client import Gateway, Scenario, eventually, object_value, string_value +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 Response, ResponseStreamEvent +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") + + +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(model: str) -> Reply: + return Reply( + body=json.dumps( + {"object": "list", "data": [{"id": model, "object": "model", "created": 1, "owned_by": "openai"}]} + ).encode() + ) + + +def drain_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 _retrieve_or_missing_deployment(client: openai.OpenAI, response_id: str) -> Response | openai.BadRequestError: + try: + return client.responses.retrieve(response_id) + except openai.BadRequestError as refusal: + if "no healthy deployments" not in str(refusal).lower(): + raise + return refusal + + +def _response(identity: str, status: str = "completed") -> bytes: + return json.dumps( + { + "id": identity, + "object": "response", + "created_at": 1, + "status": status, + "model": _MODEL, + "output": [], + } + ).encode() + + +def _stream(identity: str) -> tuple[bytes, ...]: + return tuple( + f"data: {json.dumps(event)}\n\n".encode() + for event in ( + {"type": "response.created", "response": json.loads(_response(identity, "in_progress"))}, + {"type": "response.completed", "response": json.loads(_response(identity))}, + ) + ) + (b"data: [DONE]\n\n",) + + +def _deployment(scenario: Scenario, wire: Wire, api_key: str = _API_KEY) -> 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 _stream_response_events( + client: openai.OpenAI, response_id: str +) -> tuple[tuple[ResponseStreamEvent, ...], str | None]: + try: + return ( + tuple( + client.responses.retrieve( + response_id, + stream=True, + starting_after=3, + include=["reasoning.encrypted_content"], + ) + ), + None, + ) + except openai.InternalServerError as error: + return (), error.response.text + + +def _register_preferred_in_group(scenario: Scenario, wire: Wire, group: str, api_key: str) -> None: + existing: Final = _deployments_in_group(scenario.gateway, group) + created: Final = scenario.gateway.post( + "/model/new", + { + "model_name": group, + "litellm_params": { + "model": f"openai/{_MODEL}", + "api_key": api_key, + "api_base": f"{wire.url}/v1", + "order": 1, + }, + "model_info": {}, + }, + ) + deployment_id: Final = string_value(object_value(created["model_info"])["id"]) + scenario.cleanups.callback(scenario.delete_model, deployment_id) + _wait_for_workers(scenario.gateway, group, existing + 1) + + +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, + ) + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_retrieve_routes_to_the_deployment_that_created_the_response( + gateway: Gateway, + prefix: str, +) -> None: + raw_id: Final = f"resp_affinity_{uuid.uuid4().hex}" + wire_b_key: Final = "synthetic-responses-key-b" + wire_a_key: Final = "synthetic-responses-key-a" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + if request.method == "POST": + return Reply(body=_response(raw_id)) + return Reply(body=_response(raw_id)) + + with wire_server(respond) as wire_b, wire_server(respond) as wire_a, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire_b, wire_b_key) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="create for affinity") + client_id: Final = created.id + assert client_id.startswith("resp_") and client_id != raw_id, created.model_dump_json() + _register_preferred_in_group(scenario, wire_a, model, wire_a_key) + retrieved: Final = client.responses.retrieve(client_id) + assert retrieved.id != raw_id, retrieved.model_dump_json() + assert same_response(retrieved.id, client_id), retrieved.model_dump_json() + assert retrieved.model_dump(mode="json", exclude_none=True) == { + "id": retrieved.id, + "object": "response", + "created_at": 1.0, + "status": "completed", + "model": _MODEL, + "output": [], + }, retrieved.model_dump_json() + requests: Final = drain_contract_requests(wire_b) + assert [(request.method, request.target, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("GET", f"/v1/responses/{raw_id}", b""), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "create for affinity", + }, requests[0].body + assert all(request.headers["authorization"] == f"Bearer {wire_b_key}" for request in requests), requests + assert requests[1].body == b"", requests[1] + assert drain_contract_requests(wire_a) == (), ( + f"{prefix or '/responses'} did not preserve response deployment affinity" + ) + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_retrieve_returns_the_id_the_client_holds(gateway: Gateway, prefix: str) -> None: + pytest.skip( + "BUG: GET /v1/responses/{id} returns a re-encrypted id that differs byte-for-byte from the id the client sent" + ) + raw_id: Final = f"resp_retrieve_id_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.method == "POST": + assert request.target == "/v1/responses" + assert _JSON_OBJECT.validate_json(request.body) == {"model": _MODEL, "input": "retrieve client id"}, ( + request.body + ) + return Reply(body=_response(raw_id)) + assert request.method == "GET" + assert request.target == f"/v1/responses/{raw_id}", request.target + assert request.body == b"" + return Reply(body=_response(raw_id)) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="retrieve client id") + client_id: Final = created.id + retrieved: Final = client.responses.retrieve(client_id) + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("GET", f"/v1/responses/{raw_id}", b""), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "retrieve client id", + }, requests[0].body + assert retrieved.id == client_id, (retrieved.model_dump_json(), requests) + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_streamed_response_ids_are_stable_and_retrievable_on_each_alias( + gateway: Gateway, + prefix: str, +) -> None: + raw_id: Final = f"resp_stream_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.method == "POST": + assert request.target == "/v1/responses" + assert _JSON_OBJECT.validate_json(request.body) == { + "model": _MODEL, + "input": "stream lifecycle", + "stream": True, + }, request.body + return Reply(content_type="text/event-stream", chunks=_stream(raw_id)) + return Reply(body=_response(raw_id)) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + team: Final = scenario.team(models=[model]) + user: Final = scenario.member(team) + key: Final = scenario.key(team_id=team, user_id=user, models=[model]) + client: Final = _client(gateway, key, prefix) + stream: Final = client.responses.create(model=model, input="stream lifecycle", stream=True) + events: Final = list(stream) + response_ids: Final = tuple(event.response.id for event in events if hasattr(event, "response")) + assert response_ids and all(response_id == response_ids[0] for response_id in response_ids), events + streamed_id: Final = response_ids[0] + assert streamed_id.startswith("resp_") and streamed_id != raw_id, events + retrieved: Final = eventually( + lambda: _retrieve_or_missing_deployment(client, streamed_id), + lambda outcome: not isinstance(outcome, openai.BadRequestError), + seconds=60, + ) + assert isinstance(retrieved, Response), retrieved + assert same_response(retrieved.id, streamed_id), retrieved.model_dump_json() + assert retrieved.model_dump(mode="json", exclude_none=True) == { + "id": retrieved.id, + "object": "response", + "created_at": 1.0, + "status": "completed", + "model": _MODEL, + "output": [], + }, retrieved.model_dump_json() + requests: Final = drain_contract_requests(wire) + assert [(request.method, urlsplit(request.target).path, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("GET", f"/v1/responses/{raw_id}", b""), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "stream lifecycle", + "stream": True, + }, requests[0].body + assert all(request.headers["authorization"] == f"Bearer {_API_KEY}" for request in requests), requests + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_retrieve_query_reaches_the_provider(gateway: Gateway, prefix: str) -> None: + pytest.skip( + "BUG: GET /v1/responses/{id} drops include and starting_after; streamed GET returns 500 for provider SSE" + ) + normal_raw_id: Final = f"resp_query_normal_{uuid.uuid4().hex}" + stream_raw_id: Final = f"resp_query_stream_{uuid.uuid4().hex}" + normal_include: Final = ["message.output_text.logprobs", "reasoning.encrypted_content"] + stream_include: Final = ["reasoning.encrypted_content"] + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + if request.method == "POST" and urlsplit(request.target).path == "/v1/responses": + body: Final = _JSON_OBJECT.validate_json(request.body) + identity: Final = stream_raw_id if body.get("input") == "query retrieve stream" else normal_raw_id + return Reply(body=_response(identity)) + if urlsplit(request.target).path == f"/v1/responses/{stream_raw_id}": + return Reply(content_type="text/event-stream", chunks=_stream(stream_raw_id)) + return Reply(body=_response(normal_raw_id)) + + with wire_server(respond) as wire: + sdk_client: Final = openai.OpenAI(base_url=f"{wire.url}/v1", api_key=_API_KEY, max_retries=0) + sdk_client.responses.retrieve(normal_raw_id, include=normal_include) + tuple( + sdk_client.responses.retrieve( + stream_raw_id, + stream=True, + starting_after=3, + include=stream_include, + ) + ) + sdk_requests: Final = drain_contract_requests(wire) + assert [request.method for request in sdk_requests] == ["GET", "GET"], sdk_requests + expected_queries: Final = tuple( + tuple(sorted(parse_qsl(urlsplit(request.target).query, keep_blank_values=True))) for request in sdk_requests + ) + + with gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + normal_created: Final = client.responses.create(model=model, input="query retrieve normal") + stream_created: Final = client.responses.create(model=model, input="query retrieve stream") + retrieved_response: Final = client.responses.with_raw_response.retrieve( + normal_created.id, + include=normal_include, + ) + retrieved: Final = retrieved_response.parse() + assert retrieved.id != normal_raw_id and same_response(retrieved.id, normal_created.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": [], + }, retrieved_response.text + stream_result: Final = _stream_response_events(client, stream_created.id) + stream_events: Final = stream_result[0] + stream_error_text: Final = stream_result[1] + if stream_error_text is not None: + requests: Final = drain_contract_requests(wire) + assert [(request.method, urlsplit(request.target).path, request.body) for request in requests[2:]] == [ + ("GET", f"/v1/responses/{normal_raw_id}", b""), + ("GET", f"/v1/responses/{stream_raw_id}", b""), + ], stream_error_text + actual_queries: Final = tuple( + tuple(sorted(parse_qsl(urlsplit(request.target).query, keep_blank_values=True))) + for request in requests[2:] + ) + assert actual_queries == expected_queries, ( + requests[2:], + actual_queries, + expected_queries, + stream_error_text, + ) + pytest.fail(f"Streaming retrieval returned an API error: {stream_error_text}") + completed_events: Final = tuple(event for event in stream_events if event.type == "response.completed") + assert len(completed_events) == 1, stream_events + completed: Final = completed_events[0] + assert completed.response.id != stream_raw_id and same_response(completed.response.id, stream_created.id), ( + stream_events + ) + assert completed.model_dump(mode="json", exclude_none=True) == { + "type": "response.completed", + "response": { + "id": completed.response.id, + "object": "response", + "created_at": 1.0, + "status": "completed", + "model": _MODEL, + "output": [], + }, + }, stream_events + requests: Final = drain_contract_requests(wire) + assert [(request.method, urlsplit(request.target).path, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("POST", "/v1/responses", requests[1].body), + ("GET", f"/v1/responses/{normal_raw_id}", b""), + ("GET", f"/v1/responses/{stream_raw_id}", b""), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "query retrieve normal", + }, requests[0].body + assert _JSON_OBJECT.validate_json(requests[1].body) == { + "model": _MODEL, + "input": "query retrieve stream", + }, requests[1].body + actual_queries: Final = tuple( + tuple(sorted(parse_qsl(urlsplit(request.target).query, keep_blank_values=True))) + for request in requests[2:] + ) + assert actual_queries == expected_queries, requests + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_delete_forwards_the_provider_id_once_and_reports_deletion( + gateway: Gateway, + prefix: str, +) -> None: + raw_id: Final = f"resp_delete_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.method == "POST": + assert request.target == "/v1/responses" + assert _JSON_OBJECT.validate_json(request.body) == {"model": _MODEL, "input": "delete lifecycle"}, ( + request.body + ) + return Reply(body=_response(raw_id)) + assert request.method == "DELETE" + assert request.body == b"" + return Reply(body=json.dumps({"id": raw_id, "object": "response", "deleted": True}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="delete lifecycle") + deleted: Final = client.responses.with_raw_response.delete(created.id) + assert deleted.status_code == 200, deleted.text + parsed: Final = _DeleteResponse.model_validate_json(deleted.content) + assert same_response(parsed.id, created.id), deleted.text + assert parsed.model_dump(mode="json", exclude={"id"}) == { + "object": "response", + "deleted": True, + }, deleted.text + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("DELETE", f"/v1/responses/{raw_id}", b""), + ], requests + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_delete_returns_the_client_held_id_not_the_provider_id( + gateway: Gateway, + prefix: str, +) -> None: + pytest.skip("BUG: DELETE /v1/responses/{id} returns the raw provider id instead of the proxy-issued id") + raw_id: Final = f"resp_delete_id_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.method == "POST": + assert request.target == "/v1/responses" + assert _JSON_OBJECT.validate_json(request.body) == {"model": _MODEL, "input": "delete client id"}, ( + request.body + ) + return Reply(body=_response(raw_id)) + assert request.method == "DELETE" + assert request.target == f"/v1/responses/{raw_id}", request.target + assert request.body == b"" + return Reply(body=json.dumps({"id": raw_id, "object": "response", "deleted": True}).encode()) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="delete client id") + deleted: Final = client.responses.with_raw_response.delete(created.id) + assert deleted.status_code == 200, deleted.text + parsed: Final = _DeleteResponse.model_validate_json(deleted.content) + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target, request.body) for request in requests] == [ + ("POST", "/v1/responses", requests[0].body), + ("DELETE", f"/v1/responses/{raw_id}", b""), + ], requests + assert _JSON_OBJECT.validate_json(requests[0].body) == { + "model": _MODEL, + "input": "delete client id", + }, requests[0].body + assert parsed.id != raw_id, (deleted.text, requests) + assert parsed.model_dump(mode="json") == { + "id": created.id, + "object": "response", + "deleted": True, + }, deleted.text + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_cancel_forwards_provider_id_and_returns_cancelled_response( + gateway: Gateway, + prefix: str, +) -> None: + raw_id: Final = f"resp_cancel_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers) + if request.method == "POST" and request.target == "/v1/responses": + assert _JSON_OBJECT.validate_json(request.body) == { + "model": _MODEL, + "input": "cancel lifecycle", + "background": True, + }, request.body + return Reply(body=_response(raw_id, "queued")) + assert request.body in (b"", b"{}") + return Reply(body=_response(raw_id, "cancelled")) + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="cancel lifecycle", background=True) + cancelled: Final = client.responses.cancel(created.id) + assert cancelled.id != raw_id and same_response(cancelled.id, created.id), cancelled.model_dump_json() + assert cancelled.model_dump(mode="json", exclude_none=True) == { + "id": cancelled.id, + "object": "response", + "created_at": 1.0, + "status": "cancelled", + "model": _MODEL, + "output": [], + }, cancelled.model_dump_json() + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target) for request in requests] == [ + ("POST", "/v1/responses"), + ("POST", f"/v1/responses/{raw_id}/cancel"), + ], requests + assert requests[1].body == b"{}", requests[1] + + +@pytest.mark.parametrize("prefix", _ALIASES) +def test_input_items_pagination_query_reaches_the_provider(gateway: Gateway, prefix: str) -> None: + pytest.skip( + "BUG: GET /v1/responses/{id}/input_items drops pagination and include query params; " + "upstream receives limit=20&order=desc without after or include" + ) + raw_id: Final = f"resp_input_items_{uuid.uuid4().hex}" + + def respond(request: Request) -> Reply: + if request.method == "GET" and request.target == "/v1/models": + return model_discovery_reply(_MODEL) + if request.method == "POST": + return Reply(body=_response(raw_id)) + return Reply(body=b'{"data":[],"has_more":false,"object":"list"}') + + with wire_server(respond) as wire, gateway.scenario() as scenario: + model: Final = _deployment(scenario, wire) + client: Final = _client(gateway, gateway.key, prefix) + created: Final = client.responses.create(model=model, input="list response input items") + page: Final = client.responses.input_items.list( + created.id, + limit=2, + order="asc", + after="item_after", + include=["message.output_text.logprobs"], + ) + assert page.data == [], page.model_dump_json() + requests: Final = drain_contract_requests(wire) + assert [(request.method, request.target) for request in requests] == [ + ("POST", "/v1/responses"), + ( + "GET", + f"/v1/responses/{raw_id}/input_items?limit=2&order=asc&after=item_after" + "&include=message.output_text.logprobs", + ), + ], requests + assert all(request.headers["authorization"] == f"Bearer {_API_KEY}" for request in requests), requests + assert requests[1].body == b"", requests[1]