Skip to content

Chapter 18: Serialization (JSON) - Communicating with OpenAI/REST APIs

Theoretical Foundations

In the architecture of AI-driven applications, the integrity of data transmission between your local object-oriented models and external services like OpenAI's REST API is paramount. This subsection establishes the theoretical foundation for serialization, specifically focusing on JSON, as the universal language for these interactions. We will explore how Python's pydantic and dataclasses provide the structural rigidity required for complex tensor data, and how lambda expressions serve as the glue for dynamic data transformation during these exchanges.

The Universal Translator: JSON and Object-Oriented Models

At its core, serialization is the process of translating an in-memory object structure into a format that can be stored (for example, in a database) or transmitted (for example, over a network) and reconstructed later in the same or another computer environment. In the context of AI, where we often deal with complex configurations, tensor metadata, and prompt structures, JSON (JavaScript Object Notation) has emerged as the de facto standard for REST API communication.

Why JSON? It is lightweight, human-readable, and, most importantly, natively supported by virtually every programming language, including Python and the backend services hosting Large Language Models (LLMs).

However, a direct translation from a Python class instance to a JSON string is rarely straightforward. Python objects can contain methods, circular references, and non-standard types (like NumPy arrays or custom tensor objects) that JSON cannot natively represent. Therefore, we need a robust strategy to map our Domain Models (the Python classes representing our AI logic) to Data Transfer Objects (DTOs)—structures strictly defined for serialization.

Structural Validation with Pydantic

In previous chapters, we utilized Python's standard dataclasses to reduce boilerplate code when defining simple data structures. While dataclasses provide a clean way to define the shape of data, they lack built-in mechanisms for validation. When communicating with an external API like OpenAI, we cannot afford to send malformed data; a missing field or an incorrect type can result in cryptic API errors or, worse, silent failures in model inference.

This is where Pydantic enters the theoretical landscape. Pydantic is a data validation library that enforces type hints at runtime. It forces us to be explicit about our data contracts.

Consider a PromptConfig object used to configure an interaction with an LLM. It requires specific fields: the model name, the temperature (a float between 0.0 and 2.0), and the input messages.

from pydantic import BaseModel, Field, ValidationError
from typing import List, Dict, Any

class Message(BaseModel):
    role: str  # e.g., "system", "user", "assistant"
    content: str

class PromptConfig(BaseModel):
    model: str = Field(..., description="The ID of the model to use")
    temperature: float = Field(..., ge=0.0, le=2.0, description="Controls randomness")
    messages: List[Message]
    metadata: Dict[str, Any] = {} # Flexible field for tensor metadata

Theoretical Implication: By inheriting from BaseModel, Pydantic automatically generates a dict() method that serializes the object into a JSON-compatible dictionary. More importantly, it performs coercion and validation. If we attempt to instantiate PromptConfig with temperature=2.5, Pydantic raises a ValidationError immediately, preventing invalid data from ever reaching the API. This is the "safety net" in AI data structures.

The Role of Lambda Expressions in Data Transformation

As we transition from defining static structures to handling dynamic data flows, we often need to transform data on the fly before serialization. This is where Lambda Expressions become critical. A lambda is an anonymous, inline function defined without a name. In Python, they are syntactically restricted to a single expression.

In the context of communicating with AI APIs, we frequently need to map our internal domain models to the specific schema required by the external service. For instance, OpenAI's API expects a specific JSON structure for messages. If our internal system uses a different property name (e.g., user_input vs content), we cannot simply dump our object; we must transform it.

Lambda expressions allow us to define concise transformation logic right where it is needed, avoiding the overhead of defining a full named function for a one-time mapping operation.

# Internal representation of a chat session
class ChatSession:
    def __init__(self, user_input: str, system_instruction: str):
        self.input = user_input
        self.system = system_instruction

# List of internal session objects
sessions = [
    ChatSession("Explain tensors", "You are a helpful assistant"),
    ChatSession("Write Python code", "You are a code generator")
]

# Using a lambda to map internal objects to the specific JSON structure expected by OpenAI
# The lambda takes a session object and returns a dictionary
openai_payload = {
    "messages": list(map(lambda s: {"role": "user", "content": s.input}, sessions))
}

# Resulting JSON structure:
# {
#     "messages": [
#         {"role": "user", "content": "Explain tensors"},
#         {"role": "user", "content": "Write Python code"}
#     ]
# }

Here, the lambda lambda s: {"role": "user", "content": s.input} acts as a lightweight adapter. It decouples the internal data representation from the external API contract. This pattern is ubiquitous in AI pipelines where data must be reshaped rapidly, often within list comprehensions or map functions, before being passed to a JSON serializer.

Serialization of Complex Tensor Structures

The most challenging aspect of AI serialization is handling tensors. Tensors are multi-dimensional arrays used to represent data in neural networks (e.g., embeddings, attention weights). Standard JSON does not support binary data or multi-dimensional arrays natively.

When transmitting tensor metadata to an API, we often don't send the raw tensor bytes (which would be base64 encoded) but rather the descriptor of the tensor. This includes shape, dtype, and optionally, a flattened list of values.

Let's look at how we might define a structure for a tensor payload using pydantic and dataclasses.

import json
from dataclasses import dataclass, asdict
from typing import List, Tuple, Union
import numpy as np

@dataclass
class TensorDescriptor:
    """
    Represents the metadata of a tensor. 
    We use a dataclass here because the structure is simple and 
    validation is handled upstream.
    """
    shape: Tuple[int, ...]
    dtype: str
    # We store values as a flat list for JSON compatibility
    # In a real high-performance scenario, we might use base64 encoding here
    values: List[float]

class AIModelRequest(BaseModel):
    model_id: str
    input_tensor: TensorDescriptor
    parameters: dict

# --- Simulation of Tensor Data ---
# Imagine we have a 2x2 tensor representing embeddings
tensor_data = np.array([[1.1, 2.2], [3.3, 4.4]], dtype=np.float32)

# --- Serialization Process ---
# 1. Convert numpy array to our TensorDescriptor
descriptor = TensorDescriptor(
    shape=tensor_data.shape,
    dtype=str(tensor_data.dtype),
    values=tensor_data.flatten().tolist()  # Convert to Python list
)

# 2. Create the request object
request = AIModelRequest(
    model_id="gpt-4-turbo",
    input_tensor=descriptor,
    parameters={"temperature": 0.7}
)

# 3. Serialize to JSON
# Pydantic handles the dataclass automatically
json_payload = request.model_dump_json(indent=2)

print(json_payload)

Architectural Analysis:

  1. Dataclass for Descriptors: We use dataclass for TensorDescriptor because its primary role is data holding. It provides a clean, readable structure for the tensor's metadata.
  2. Pydantic for Composition: AIModelRequest uses Pydantic because it sits at the boundary of our system. It ensures that model_id is present and that input_tensor is indeed a TensorDescriptor.
  3. Flattening: Note the use of .flatten().tolist(). JSON arrays are strictly one-dimensional. To represent a 2D or 3D tensor, we must flatten the data and rely on the shape attribute to reconstruct it on the receiving end.

Theoretical Foundations

Once the data is serialized into a JSON string, it must be wrapped in an HTTP request. The theoretical foundation here relies on the REST (Representational State Transfer) architectural style. In REST, resources are manipulated using standard HTTP verbs (GET, POST, PUT, DELETE).

For AI interactions, the POST method is almost exclusively used because we are sending a payload (the prompt and configuration) to the server to generate a new resource (the completion).

The flow of data is as follows:

  1. Construction: The local Python application constructs a Pydantic model (e.g., PromptConfig).
  2. Serialization: The model is converted to a JSON string using .model_dump_json().
  3. Transmission: The JSON string is attached to the body of an HTTP POST request.
  4. Deserialization: The OpenAI server receives the JSON, parses it into its internal C++/Rust structures, processes the inference, and returns a JSON response.
  5. Validation: The local application receives the response and validates it against a Pydantic response model to ensure the API returned what was expected.

The "What If": Edge Cases and Error Handling

Theoretical robustness requires anticipating failure.

What if the API changes its schema? If OpenAI adds a new required field to the messages object, our strict Pydantic models will immediately fail validation when parsing the response (if we validate responses) or when constructing the request. This is a feature, not a bug. It prevents our application from sending requests that are technically valid JSON but semantically invalid according to the API's current specification.

What if the tensor data is too large? Serializing a massive tensor (e.g., a 10,000 x 10,000 matrix) into a JSON list of floats is inefficient. JSON is a text format; a float like 3.14159 takes up significantly more bytes than its binary representation.

  • Solution: In production AI systems, large tensors are rarely sent via JSON REST APIs. Instead, they are often:

    1. Stored in a blob storage (like S3), and only the reference URL is sent via JSON.
    2. Sent via a binary protocol like gRPC (which supports protobufs), which is more efficient for large data blobs.
    3. Base64 encoded within the JSON (still heavy, but better than text lists).

Visualizing the Data Flow

The following diagram illustrates the lifecycle of a data object as it travels from the local application to the AI model and back.

Diagram: DataFlow
Hold "Ctrl" to enable pan & zoom

Summary of Concepts

  • Serialization: The mechanism to convert complex Python objects (including tensors) into a transmittable format (JSON).
  • Pydantic: The library used to enforce strict typing and validation at the data boundary, ensuring API compatibility.
  • Dataclasses: Used for internal data structures that require less boilerplate but rely on Pydantic for boundary validation.
  • Lambda Expressions: Used for inline, anonymous data mapping to adapt internal data structures to external API schemas.
  • Tensors in JSON: Represented as flattened arrays accompanied by shape metadata, as JSON does not support multi-dimensional arrays natively.

This theoretical foundation ensures that when we move to the practical implementation of sending prompts to OpenAI, our data is structured, validated, and ready for the complexities of AI model interaction.

Basic Code Example

Real-World Context: Saving and Loading AI Model Configurations

Imagine you are building an AI application that interacts with OpenAI's API. You have defined a Python class, AIRequest, to structure the data for an API call. This class includes fields for the prompt, model version, and temperature (a hyperparameter). When your application needs to save this configuration to a file or send it over the network, it must convert the object into a standardized format like JSON. Conversely, when loading a configuration from a file, you need to parse the JSON back into a structured Python object. This process, called serialization and deserialization, ensures data integrity and interoperability between different parts of your system or external services.

Code Example: Basic Serialization with dataclasses and json

This example demonstrates how to define a data structure using Python's dataclasses module, serialize it to JSON, and deserialize it back into an object. It uses a lambda expression to handle a custom serialization step for a list of tensor-like values.

import json
from dataclasses import dataclass, asdict
from typing import List

# Define a dataclass to represent an AI request configuration.
# This provides a clear structure for our data, similar to a schema.
@dataclass
class AIRequest:
    prompt: str
    model: str
    temperature: float
    # A list representing a simple tensor (e.g., embedding dimensions)
    tensor_shape: List[int]

# 1. Create an instance of our AIRequest class.
# This represents a real configuration we might send to an API.
request_config = AIRequest(
    prompt="Explain the concept of serialization in Python.",
    model="gpt-4",
    temperature=0.7,
    tensor_shape=[1, 768]  # Example: batch size 1, embedding dimension 768
)

# 2. Serialize the object to a JSON string.
# We use `asdict` to convert the dataclass to a dictionary, then `json.dumps`.
# A lambda function is used here to demonstrate its use in a custom serialization context.
# While `json.dumps` handles basic types, we can use a lambda to process data before serialization.
# In this case, we create a dictionary representation and use a lambda to format the tensor shape for display.
config_dict = asdict(request_config)
formatted_tensor = list(map(lambda x: f"dim_{x}", config_dict['tensor_shape']))
json_string = json.dumps(config_dict)

print("--- Serialized JSON ---")
print(json_string)
print("\nFormatted Tensor (using lambda):", formatted_tensor)

# 3. Deserialize the JSON string back into a dictionary.
# This is the first step to reconstructing our object.
loaded_dict = json.loads(json_string)

# 4. Recreate the AIRequest object from the dictionary.
# We unpack the dictionary into the dataclass constructor.
restored_request = AIRequest(**loaded_dict)

print("\n--- Restored Object ---")
print(f"Prompt: {restored_request.prompt}")
print(f"Model: {restored_request.model}")
print(f"Temperature: {restored_request.temperature}")
print(f"Tensor Shape: {restored_request.tensor_shape}")

Step-by-Step Explanation

  1. Data Structure Definition: We use the @dataclass decorator to define the AIRequest class. This automatically generates methods like __init__ and __repr__, reducing boilerplate code. The type hints (str, float, List[int]) make the structure explicit and are used by tools like Pydantic for validation in more advanced scenarios.
  2. Object Instantiation: We create an instance request_config with sample data. This object now holds our structured configuration in memory.
  3. Serialization to JSON:
    • asdict(request_config) converts the dataclass instance into a standard Python dictionary.
    • json.dumps() then converts this dictionary into a JSON-formatted string. JSON is a lightweight, text-based format that is language-agnostic, making it ideal for APIs.
    • The lambda expression lambda x: f"dim_{x}" is used with map() to transform the integer tensor dimensions into descriptive strings. This demonstrates how lambda functions can be used for quick, inline data transformations during processing.
  4. Deserialization from JSON:
    • json.loads() parses the JSON string back into a Python dictionary.
    • We then use the dictionary unpacking operator ** to pass the dictionary's key-value pairs as arguments to the AIRequest constructor. This effectively reconstructs the original object.
  5. Verification: The final print statements confirm that the data was accurately preserved through the serialization-deserialization cycle.

Common Pitfalls

A frequent mistake is attempting to directly serialize a custom object (like a dataclass instance) using json.dumps() without first converting it to a supported type (like a dictionary or list). The json module only natively handles basic types: str, int, float, bool, None, list, and dict. Trying to serialize a custom class instance will result in a TypeError: Object of type 'ClassName' is not JSON serializable. Always convert your objects to a dictionary (e.g., using asdict() for dataclasses or a custom to_dict() method) before calling json.dumps().

Visualizing the Data Flow

The following diagram illustrates the flow of data between a Python object, a JSON string, and an external system (like an API).

This diagram illustrates the bidirectional data flow where a Python object is serialized into a JSON string for transmission to an external system, and conversely, an incoming JSON string is deserialized back into a Python object for processing.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the bidirectional data flow where a Python object is serialized into a JSON string for transmission to an external system, and conversely, an incoming JSON string is deserialized back into a Python object for processing.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.