diff --git a/src/models/config.py b/src/models/config.py index 8958f18f1..23c23a4fe 100644 --- a/src/models/config.py +++ b/src/models/config.py @@ -804,6 +804,8 @@ class Action(str, Enum): INFO = "info" # Allow overriding model/provider via request MODEL_OVERRIDE = "model_override" + # RHEL Lightspeed rlsapi v1 compatibility - stateless inference (no history/RAG) + RLSAPI_V1_INFER = "rlsapi_v1_infer" class AccessRule(ConfigurationBase): diff --git a/src/models/rlsapi/__init__.py b/src/models/rlsapi/__init__.py new file mode 100644 index 000000000..66925eafe --- /dev/null +++ b/src/models/rlsapi/__init__.py @@ -0,0 +1 @@ +"""Pydantic models for rlsapi v1 integration.""" diff --git a/src/models/rlsapi/requests.py b/src/models/rlsapi/requests.py new file mode 100644 index 000000000..518048683 --- /dev/null +++ b/src/models/rlsapi/requests.py @@ -0,0 +1,176 @@ +"""Models for rlsapi v1 REST API requests.""" + +from pydantic import Field, field_validator + +from models.config import ConfigurationBase + + +class RlsapiV1Attachment(ConfigurationBase): + """Attachment data from rlsapi v1 context. + + Attributes: + contents: The textual contents of the file read on the client machine. + mimetype: The MIME type of the file. + """ + + contents: str = Field( + default="", + description="File contents read on client", + examples=["# Configuration file\nkey=value"], + ) + mimetype: str = Field( + default="", + description="MIME type of the file", + examples=["text/plain", "application/json"], + ) + + +class RlsapiV1Terminal(ConfigurationBase): + """Terminal output from rlsapi v1 context. + + Attributes: + output: The textual contents of the terminal read on the client machine. + """ + + output: str = Field( + default="", + description="Terminal output from client", + examples=["bash: command not found", "Permission denied"], + ) + + +class RlsapiV1SystemInfo(ConfigurationBase): + """System information from rlsapi v1 context. + + Attributes: + os: The operating system of the client machine. + version: The version of the operating system. + arch: The architecture of the client machine. + system_id: The id of the client machine. + """ + + os: str = Field(default="", description="Operating system name", examples=["RHEL"]) + version: str = Field( + default="", description="Operating system version", examples=["9.3", "8.10"] + ) + arch: str = Field( + default="", description="System architecture", examples=["x86_64", "aarch64"] + ) + system_id: str = Field( + default="", + alias="id", + description="Client machine ID", + examples=["01JDKR8N7QW9ZMXVGK3PB5TQWZ"], + ) + + model_config = {"populate_by_name": True} + + +class RlsapiV1CLA(ConfigurationBase): + """Command Line Assistant information from rlsapi v1 context. + + Attributes: + nevra: The NEVRA (Name-Epoch-Version-Release-Architecture) of the CLA. + version: The version of the command line assistant. + """ + + nevra: str = Field( + default="", + description="CLA NEVRA identifier", + examples=["command-line-assistant-0:0.2.0-1.el9.noarch"], + ) + version: str = Field( + default="", + description="Command line assistant version", + examples=["0.2.0"], + ) + + +class RlsapiV1Context(ConfigurationBase): + """Context data for rlsapi v1 /infer request. + + Attributes: + stdin: Redirect input read by command-line-assistant. + attachments: Attachment object received by the client. + terminal: Terminal object received by the client. + systeminfo: System information object received by the client. + cla: Command Line Assistant information. + """ + + stdin: str = Field( + default="", + description="Redirect input from stdin", + examples=["piped input from previous command"], + ) + attachments: RlsapiV1Attachment = Field( + default_factory=RlsapiV1Attachment, + description="File attachment data", + ) + terminal: RlsapiV1Terminal = Field( + default_factory=RlsapiV1Terminal, + description="Terminal output context", + ) + systeminfo: RlsapiV1SystemInfo = Field( + default_factory=RlsapiV1SystemInfo, + description="Client system information", + ) + cla: RlsapiV1CLA = Field( + default_factory=RlsapiV1CLA, + description="Command line assistant metadata", + ) + + +class RlsapiV1InferRequest(ConfigurationBase): + """RHEL Lightspeed rlsapi v1 /infer request. + + Attributes: + question: User question string. + context: Context with system info, terminal output, etc. (defaults provided). + skip_rag: Whether to skip RAG retrieval (default False). + + Example: + ```python + request = RlsapiV1InferRequest( + question="How do I list files?", + context=RlsapiV1Context( + systeminfo=RlsapiV1SystemInfo(os="RHEL", version="9.3"), + terminal=RlsapiV1Terminal(output="bash: command not found"), + ), + ) + ``` + """ + + question: str = Field( + ..., + min_length=1, + description="User question", + examples=["How do I list files?", "How do I configure SELinux?"], + ) + context: RlsapiV1Context = Field( + default_factory=RlsapiV1Context, + description="Optional context (system info, terminal output, stdin, attachments)", + ) + skip_rag: bool = Field( + default=False, + description="Whether to skip RAG retrieval", + examples=[False, True], + ) + + @field_validator("question") + @classmethod + def validate_question(cls, value: str) -> str: + """Validate question is not empty or whitespace-only. + + Args: + value: The question string to validate. + + Returns: + The stripped question string. + + Raises: + ValueError: If the question is empty or whitespace-only. + """ + stripped = value.strip() + if not stripped: + raise ValueError("Question cannot be empty or whitespace-only") + return stripped diff --git a/src/models/rlsapi/responses.py b/src/models/rlsapi/responses.py new file mode 100644 index 000000000..8d7f13a74 --- /dev/null +++ b/src/models/rlsapi/responses.py @@ -0,0 +1,53 @@ +"""Models for rlsapi v1 REST API responses.""" + +from pydantic import Field + +from models.config import ConfigurationBase +from models.responses import AbstractSuccessfulResponse + + +class RlsapiV1InferData(ConfigurationBase): + """Response data for rlsapi v1 /infer endpoint. + + Attributes: + text: The generated response text. + request_id: Unique identifier for the request. + """ + + text: str = Field( + ..., + description="Generated response text", + examples=["To list files in Linux, use the `ls` command."], + ) + request_id: str | None = Field( + None, + description="Unique request identifier", + examples=["01JDKR8N7QW9ZMXVGK3PB5TQWZ"], + ) + + +class RlsapiV1InferResponse(AbstractSuccessfulResponse): + """RHEL Lightspeed rlsapi v1 /infer response. + + Attributes: + data: Response data containing text and request_id. + """ + + data: RlsapiV1InferData = Field( + ..., + description="Response data containing text and request_id", + ) + + model_config = { + "extra": "forbid", + "json_schema_extra": { + "examples": [ + { + "data": { + "text": "To list files in Linux, use the `ls` command.", + "request_id": "01JDKR8N7QW9ZMXVGK3PB5TQWZ", + } + } + ] + }, + } diff --git a/tests/unit/authentication/test_api_key_token.py b/tests/unit/authentication/test_api_key_token.py index 7fd577dd3..988494881 100644 --- a/tests/unit/authentication/test_api_key_token.py +++ b/tests/unit/authentication/test_api_key_token.py @@ -2,8 +2,8 @@ """Unit tests for functions defined in authentication/api_key_token.py""" -from fastapi import Request, HTTPException import pytest +from fastapi import HTTPException, Request from pydantic import SecretStr from authentication.api_key_token import APIKeyTokenAuthDependency @@ -71,7 +71,9 @@ async def test_api_key_with_token_auth_dependency_no_token( await dependency(request) assert exc_info.value.status_code == 401 - assert exc_info.value.detail["cause"] == "No Authorization header found" + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["cause"] == "No Authorization header found" async def test_api_key_with_token_auth_dependency_no_bearer( @@ -94,7 +96,9 @@ async def test_api_key_with_token_auth_dependency_no_bearer( await dependency(request) assert exc_info.value.status_code == 401 - assert exc_info.value.detail["cause"] == "No token found in Authorization header" + detail = exc_info.value.detail + assert isinstance(detail, dict) + assert detail["cause"] == "No token found in Authorization header" async def test_api_key_with_token_auth_dependency_invalid( diff --git a/tests/unit/models/rlsapi/__init__.py b/tests/unit/models/rlsapi/__init__.py new file mode 100644 index 000000000..e155c58b0 --- /dev/null +++ b/tests/unit/models/rlsapi/__init__.py @@ -0,0 +1 @@ +"""Unit tests for rlsapi v1 models.""" diff --git a/tests/unit/models/rlsapi/test_requests.py b/tests/unit/models/rlsapi/test_requests.py new file mode 100644 index 000000000..27ad32076 --- /dev/null +++ b/tests/unit/models/rlsapi/test_requests.py @@ -0,0 +1,308 @@ +# pylint: disable=no-member +"""Unit tests for rlsapi v1 request models.""" + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from models.rlsapi.requests import ( + RlsapiV1Attachment, + RlsapiV1CLA, + RlsapiV1Context, + RlsapiV1InferRequest, + RlsapiV1SystemInfo, + RlsapiV1Terminal, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(name="sample_systeminfo") +def sample_systeminfo_fixture() -> RlsapiV1SystemInfo: + """Create a sample RlsapiV1SystemInfo for testing.""" + return RlsapiV1SystemInfo(os="RHEL", version="9.3", arch="x86_64") + + +@pytest.fixture(name="sample_context") +def sample_context_fixture(sample_systeminfo: RlsapiV1SystemInfo) -> RlsapiV1Context: + """Create a sample RlsapiV1Context for testing.""" + return RlsapiV1Context( + stdin="piped input", + systeminfo=sample_systeminfo, + terminal=RlsapiV1Terminal(output="bash: command not found"), + ) + + +@pytest.fixture(name="sample_request") +def sample_request_fixture(sample_context: RlsapiV1Context) -> RlsapiV1InferRequest: + """Create a sample RlsapiV1InferRequest for testing.""" + return RlsapiV1InferRequest( + question="How do I list files?", + context=sample_context, + skip_rag=True, + ) + + +# --------------------------------------------------------------------------- +# Parameterized tests for common patterns +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("model_class", "valid_kwargs"), + [ + (RlsapiV1Attachment, {"contents": "test"}), + (RlsapiV1Terminal, {"output": "test"}), + (RlsapiV1SystemInfo, {"os": "RHEL"}), + (RlsapiV1CLA, {"nevra": "test"}), + (RlsapiV1Context, {"stdin": "test"}), + (RlsapiV1InferRequest, {"question": "test"}), + ], + ids=[ + "Attachment", + "Terminal", + "SystemInfo", + "CLA", + "Context", + "InferRequest", + ], +) +def test_extra_fields_forbidden( + model_class: type[BaseModel], valid_kwargs: dict[str, Any] +) -> None: + """Test that extra fields are rejected for all models with extra='forbid'.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + model_class(**valid_kwargs, extra_field="not allowed") # type: ignore[call-arg] + + +@pytest.mark.parametrize( + ("model_class", "expected_defaults"), + [ + (RlsapiV1Attachment, {"contents": "", "mimetype": ""}), + (RlsapiV1Terminal, {"output": ""}), + (RlsapiV1CLA, {"nevra": "", "version": ""}), + ], + ids=["Attachment", "Terminal", "CLA"], +) +def test_simple_model_defaults( + model_class: type[BaseModel], expected_defaults: dict[str, str] +) -> None: + """Test that simple models have empty string defaults.""" + instance = model_class() + for field_name, expected_value in expected_defaults.items(): + assert getattr(instance, field_name) == expected_value + + +# --------------------------------------------------------------------------- +# RlsapiV1Attachment tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1Attachment: # pylint: disable=too-few-public-methods + """Test cases for RlsapiV1Attachment model.""" + + def test_constructor_with_values(self) -> None: + """Test RlsapiV1Attachment with provided values.""" + attachment = RlsapiV1Attachment( + contents="file contents here", + mimetype="text/plain", + ) + assert attachment.contents == "file contents here" + assert attachment.mimetype == "text/plain" + + +# --------------------------------------------------------------------------- +# RlsapiV1Terminal tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1Terminal: # pylint: disable=too-few-public-methods + """Test cases for RlsapiV1Terminal model.""" + + def test_constructor_with_values(self) -> None: + """Test RlsapiV1Terminal with provided values.""" + terminal = RlsapiV1Terminal(output="bash: ls: command not found") + assert terminal.output == "bash: ls: command not found" + + +# --------------------------------------------------------------------------- +# RlsapiV1SystemInfo tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1SystemInfo: + """Test cases for RlsapiV1SystemInfo model.""" + + def test_constructor_defaults(self) -> None: + """Test RlsapiV1SystemInfo with default values.""" + sysinfo = RlsapiV1SystemInfo() + assert sysinfo.os == "" + assert sysinfo.version == "" + assert sysinfo.arch == "" + assert sysinfo.system_id == "" + + def test_constructor_with_values(self) -> None: + """Test RlsapiV1SystemInfo with provided values.""" + sysinfo = RlsapiV1SystemInfo( + os="RHEL", + version="9.3", + arch="x86_64", + system_id="machine-001", + ) + assert sysinfo.os == "RHEL" + assert sysinfo.version == "9.3" + assert sysinfo.arch == "x86_64" + assert sysinfo.system_id == "machine-001" + + @pytest.mark.parametrize( + ("kwargs", "expected_id"), + [ + ({"id": "alias-machine-id"}, "alias-machine-id"), + ({"system_id": "direct-machine-id"}, "direct-machine-id"), + ], + ids=["via_alias", "via_field_name"], + ) + def test_system_id_population( + self, kwargs: dict[str, str], expected_id: str + ) -> None: + """Test system_id can be set via alias 'id' or directly.""" + sysinfo = RlsapiV1SystemInfo(**kwargs) # type: ignore[arg-type] + assert sysinfo.system_id == expected_id + + +# --------------------------------------------------------------------------- +# RlsapiV1CLA tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1CLA: # pylint: disable=too-few-public-methods + """Test cases for RlsapiV1CLA model.""" + + def test_constructor_with_values(self) -> None: + """Test RlsapiV1CLA with provided values.""" + cla = RlsapiV1CLA( + nevra="command-line-assistant-0.1.0-1.el9.noarch", + version="0.1.0", + ) + assert cla.nevra == "command-line-assistant-0.1.0-1.el9.noarch" + assert cla.version == "0.1.0" + + +# --------------------------------------------------------------------------- +# RlsapiV1Context tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1Context: + """Test cases for RlsapiV1Context model.""" + + def test_constructor_defaults(self) -> None: + """Test RlsapiV1Context with default values.""" + context = RlsapiV1Context() + assert context.stdin == "" + assert isinstance(context.attachments, RlsapiV1Attachment) + assert isinstance(context.terminal, RlsapiV1Terminal) + assert isinstance(context.systeminfo, RlsapiV1SystemInfo) + assert isinstance(context.cla, RlsapiV1CLA) + + def test_constructor_with_nested_models(self) -> None: + """Test RlsapiV1Context with nested model values.""" + context = RlsapiV1Context( + stdin="piped input", + attachments=RlsapiV1Attachment( + contents="config file", + mimetype="application/yaml", + ), + terminal=RlsapiV1Terminal(output="error output"), + systeminfo=RlsapiV1SystemInfo(os="RHEL", version="9.3"), + cla=RlsapiV1CLA(version="0.1.0"), + ) + assert context.stdin == "piped input" + assert context.attachments.contents == "config file" + assert context.terminal.output == "error output" + assert context.systeminfo.os == "RHEL" + assert context.cla.version == "0.1.0" + + def test_constructor_with_dict_nested(self) -> None: + """Test RlsapiV1Context with dict values for nested models.""" + context = RlsapiV1Context( + terminal={"output": "from dict"}, # type: ignore[arg-type] + systeminfo={"os": "RHEL", "version": "9.3"}, # type: ignore[arg-type] + ) + assert context.terminal.output == "from dict" + assert context.systeminfo.os == "RHEL" + + +# --------------------------------------------------------------------------- +# RlsapiV1InferRequest tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1InferRequest: + """Test cases for RlsapiV1InferRequest model.""" + + def test_constructor_minimal(self) -> None: + """Test RlsapiV1InferRequest with only required field.""" + request = RlsapiV1InferRequest(question="How do I list files?") + assert request.question == "How do I list files?" + assert isinstance(request.context, RlsapiV1Context) + assert request.skip_rag is False + + def test_constructor_full(self, sample_request: RlsapiV1InferRequest) -> None: + """Test RlsapiV1InferRequest with all fields via fixture.""" + assert sample_request.question == "How do I list files?" + assert sample_request.context.systeminfo.os == "RHEL" + assert sample_request.context.terminal.output == "bash: command not found" + assert sample_request.skip_rag is True + + @pytest.mark.parametrize( + ("question", "error_match"), + [ + pytest.param(None, "Field required", id="missing"), + pytest.param("", "String should have at least 1 character", id="empty"), + pytest.param( + " ", "Question cannot be empty or whitespace-only", id="whitespace" + ), + ], + ) + def test_question_validation(self, question: str | None, error_match: str) -> None: + """Test question field validation for various invalid inputs.""" + with pytest.raises(ValidationError, match=error_match): + if question is None: + RlsapiV1InferRequest() # type: ignore[call-arg] + else: + RlsapiV1InferRequest(question=question) + + def test_question_stripped(self) -> None: + """Test that question is stripped of leading/trailing whitespace.""" + request = RlsapiV1InferRequest(question=" How do I list files? ") + assert request.question == "How do I list files?" + + def test_docstring_example(self) -> None: + """Test the example from the docstring works correctly.""" + request = RlsapiV1InferRequest( + question="How do I list files?", + context=RlsapiV1Context( + systeminfo=RlsapiV1SystemInfo(os="RHEL", version="9.3"), + terminal=RlsapiV1Terminal(output="bash: command not found"), + ), + ) + assert request.question == "How do I list files?" + assert request.context.systeminfo.os == "RHEL" + assert request.context.systeminfo.version == "9.3" + assert request.context.terminal.output == "bash: command not found" + + def test_serialization_roundtrip( + self, sample_request: RlsapiV1InferRequest + ) -> None: + """Test that model can be serialized and deserialized.""" + json_data = sample_request.model_dump_json() + restored = RlsapiV1InferRequest.model_validate_json(json_data) + + assert restored.question == sample_request.question + assert restored.skip_rag == sample_request.skip_rag + assert restored.context.systeminfo.os == sample_request.context.systeminfo.os diff --git a/tests/unit/models/rlsapi/test_responses.py b/tests/unit/models/rlsapi/test_responses.py new file mode 100644 index 000000000..4fe72dc76 --- /dev/null +++ b/tests/unit/models/rlsapi/test_responses.py @@ -0,0 +1,159 @@ +# pylint: disable=no-member +"""Unit tests for rlsapi v1 response models.""" + +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from models.rlsapi.responses import ( + RlsapiV1InferData, + RlsapiV1InferResponse, +) +from models.responses import AbstractSuccessfulResponse + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(name="sample_data") +def sample_data_fixture() -> RlsapiV1InferData: + """Create a sample RlsapiV1InferData for testing.""" + return RlsapiV1InferData( + text="To list files in Linux, use the `ls` command.", + request_id="01JDKR8N7QW9ZMXVGK3PB5TQWZ", + ) + + +@pytest.fixture(name="sample_response") +def sample_response_fixture(sample_data: RlsapiV1InferData) -> RlsapiV1InferResponse: + """Create a sample RlsapiV1InferResponse for testing.""" + return RlsapiV1InferResponse(data=sample_data) + + +# --------------------------------------------------------------------------- +# Parameterized tests for common patterns +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("model_class", "valid_kwargs"), + [ + (RlsapiV1InferData, {"text": "test"}), + (RlsapiV1InferResponse, {"data": {"text": "test"}}), + ], + ids=["InferData", "InferResponse"], +) +def test_extra_fields_forbidden( + model_class: type[BaseModel], valid_kwargs: dict[str, Any] +) -> None: + """Test that extra fields are rejected for all models with extra='forbid'.""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + model_class(**valid_kwargs, extra_field="not allowed") # type: ignore[call-arg] + + +@pytest.mark.parametrize( + ("model_class", "error_match"), + [ + (RlsapiV1InferData, "Field required"), + (RlsapiV1InferResponse, "Field required"), + ], + ids=["InferData", "InferResponse"], +) +def test_required_fields(model_class: type[BaseModel], error_match: str) -> None: + """Test that required fields raise ValidationError when missing.""" + with pytest.raises(ValidationError, match=error_match): + model_class() # type: ignore[call-arg] + + +# --------------------------------------------------------------------------- +# RlsapiV1InferData tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1InferData: + """Test cases for RlsapiV1InferData model.""" + + def test_constructor_minimal(self) -> None: + """Test RlsapiV1InferData with only required field.""" + data = RlsapiV1InferData(text="Response text here") + assert data.text == "Response text here" + assert data.request_id is None + + def test_constructor_full(self, sample_data: RlsapiV1InferData) -> None: + """Test RlsapiV1InferData with all fields via fixture.""" + assert sample_data.text == "To list files in Linux, use the `ls` command." + assert sample_data.request_id == "01JDKR8N7QW9ZMXVGK3PB5TQWZ" + + +# --------------------------------------------------------------------------- +# RlsapiV1InferResponse tests +# --------------------------------------------------------------------------- + + +class TestRlsapiV1InferResponse: + """Test cases for RlsapiV1InferResponse model.""" + + def test_constructor(self, sample_response: RlsapiV1InferResponse) -> None: + """Test RlsapiV1InferResponse with valid data via fixture.""" + assert isinstance(sample_response, AbstractSuccessfulResponse) + assert ( + sample_response.data.text == "To list files in Linux, use the `ls` command." + ) + assert sample_response.data.request_id == "01JDKR8N7QW9ZMXVGK3PB5TQWZ" + + def test_constructor_with_dict(self) -> None: + """Test RlsapiV1InferResponse with dict value for data.""" + response = RlsapiV1InferResponse( + data={ # type: ignore[arg-type] + "text": "Response from dict", + "request_id": "test-123", + } + ) + assert response.data.text == "Response from dict" + assert response.data.request_id == "test-123" + + def test_inherits_from_abstract_successful_response( + self, sample_response: RlsapiV1InferResponse + ) -> None: + """Test that RlsapiV1InferResponse inherits from AbstractSuccessfulResponse.""" + assert isinstance(sample_response, AbstractSuccessfulResponse) + + def test_openapi_response(self) -> None: + """Test RlsapiV1InferResponse.openapi_response() method.""" + result = RlsapiV1InferResponse.openapi_response() + assert result["description"] == "Successful response" + assert result["model"] == RlsapiV1InferResponse + assert "content" in result + assert "application/json" in result["content"] + assert "example" in result["content"]["application/json"] + + example = result["content"]["application/json"]["example"] + assert "data" in example + assert "text" in example["data"] + assert "request_id" in example["data"] + + def test_json_schema_example(self) -> None: + """Test that JSON schema has proper example.""" + schema = RlsapiV1InferResponse.model_json_schema() + examples = schema.get("examples", []) + assert len(examples) == 1 + + example = examples[0] + assert "data" in example + assert ( + example["data"]["text"] == "To list files in Linux, use the `ls` command." + ) + assert example["data"]["request_id"] == "01JDKR8N7QW9ZMXVGK3PB5TQWZ" + + def test_serialization_roundtrip( + self, sample_response: RlsapiV1InferResponse + ) -> None: + """Test that model can be serialized and deserialized.""" + json_data = sample_response.model_dump_json() + restored = RlsapiV1InferResponse.model_validate_json(json_data) + + assert restored.data.text == sample_response.data.text + assert restored.data.request_id == sample_response.data.request_id