Modular API

Requires BAML version >=0.79.0

First and foremost, BAML provides a high level API where functions are a first class citizen and their execution is fully transparent to the developer. This means that you can simply call a BAML function and everything from prompt rendering, HTTP request building, LLM API network call and response parsing is handled for you. Basic example:

BAML
class Resume {
name string
experience string[]
education string[]
}
function ExtractResume(resume: string) -> Resume {
client "openai-responses/gpt-5"
prompt #"
Extract the following information from the resume:
---
{{ resume }}
---
{{ ctx.output_format }}
"#
}

Now we can use this function in our server code after running baml-cli generate:

from baml_client import b
async def run():
# HTTP request + LLM response parsing.
resume = await b.ExtractResume("John Doe | Software Engineer | BSc in CS")
print(resume)

However, sometimes we may want to execute a function without so much abstraction or have access to the HTTP request before sending it. For this, BAML provides a lower level API that exposes the HTTP request and LLM response parser to the caller. Here’s an example that uses the requests library in Python, the fetch API in Node.js and the Net::HTTP library in Ruby to manually send an HTTP request to OpenAI’s API and parse the LLM response.

import requests
# requests is not async so for simplicity we'll use the sync client.
from baml_client.sync_client import b
def run():
# Get the HTTP request object.
req = b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
# Send the HTTP request.
res = requests.post(url=req.url, headers=req.headers, json=req.body.json())
# Parse the LLM response.
parsed = b.parse.ExtractResume(res.json()["choices"][0]["message"]["content"])
# Fully parsed Resume type.
print(parsed)

Note that request.body.json() returns an object (dict in Python, hash in Ruby) which we are then serializing to JSON, but request.body also exposes the raw binary buffer so we can skip the serialization:

res = requests.post(url=req.url, headers=req.headers, data=req.body.raw())

Using Provider SDKs

We can use the same modular API with the official SDKs. Here are some examples:

OpenAI Chat Completions API

from openai import AsyncOpenAI
from baml_client import b
async def run():
# Initialize the OpenAI client.
client = AsyncOpenAI()
# Get the HTTP request object.
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
# Use the openai library to send the request.
res = await client.chat.completions.create(**req.body.json())
# Parse the LLM response.
parsed = b.parse.ExtractResume(res.choices[0].message.content)
# Fully parsed Resume type.
print(parsed)

OpenAI Responses API

The OpenAI Responses API uses the /v1/responses endpoint and is designed for enhanced reasoning capabilities. BAML supports this through the openai-responses provider:

from openai import AsyncOpenAI
from openai.types.responses import Response
from baml_client import b
import typing
async def run():
# Initialize the OpenAI client.
client = AsyncOpenAI()
# Get the HTTP request object from a function using openai-responses provider.
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
# Use the openai responses API endpoint.
res = typing.cast(Response, await client.responses.create(**req.body.json()))
# Parse the LLM response from the responses API.
parsed = b.parse.ExtractResume(res.output_text)
# Fully parsed Resume type.
print(parsed)

Anthropic

Remember that the client is defined in the BAML function (or you can use the client registry):

BAML
function ExtractResume(resume: string) -> Resume {
client "anthropic/claude-3-5-haiku-20241022"
// Prompt here...
}
import anthropic
from baml_client import b
async def run():
# Initialize the Anthropic client.
client = anthropic.AsyncAnthropic()
# Get the HTTP request object.
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
# Use the anthropic library to send the request.
res = await client.messages.create(**req.body.json())
# Parse the LLM response.
parsed = b.parse.ExtractResume(res.content[0].text)
# Fully parsed Resume type.
print(parsed)

Google Gemini

Remember that the client is defined in the BAML function (or you can use the client registry):

BAML
function ExtractResume(resume: string) -> Resume {
client "google-ai/gemini-3.5-flash"
// Prompt here...
}
from google import genai
from baml_client import b
async def run():
# Initialize the Gemini client.
client = genai.Client()
# Get the HTTP request object.
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
# Get the request body.
body = req.body.json()
# Use the gemini library to send the request.
res = await client.aio.models.generate_content(
model="gemini-3.5-flash",
contents=body["contents"],
config={
"safety_settings": [body["safetySettings"]] # REST API uses camelCase
}
)
# Parse the LLM response.
parsed = b.parse.ExtractResume(res.text)
# Fully parsed Resume type.
print(parsed)

AWS Bedrock

The modular API now returns requests for Bedrock’s Converse API. You can modify it, sign it and forward the request with any HTTP client. A signature with the SignatureV4 SDK is required, we provide examples of how to do this below.

BAML
function ExtractResume(resume: string) -> Resume {
client Bedrock
// Prompt here...
}
import asyncio
import json
import os
import httpx
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
import boto3
from baml_client import b
from urllib.parse import urlsplit
async def run():
req = await b.request.ExtractResume("John Doe | Software Engineer | BSc in CS")
body = req.body.json()
# Optional: append your own messages before signing.
body["messages"].append({
"role": "system",
"content": [{"text": "You must respond in JSON."}],
})
body_string = json.dumps(body)
body_bytes = body_string.encode("utf-8")
session = boto3.Session()
credentials = session.get_credentials().get_frozen_credentials()
region = (
req.client_details.options.get("region")
or os.environ.get("AWS_REGION")
or os.environ.get("AWS_DEFAULT_REGION")
or session.region_name
or "us-east-1"
)
url = urlsplit(req.url)
base_headers = {
key: value
for key, value in dict(req.headers).items()
if value is not None
}
headers = {
**base_headers,
"content-type": "application/json",
"accept": "application/json",
"host": url.netloc,
}
aws_request = AWSRequest(
method=req.method,
url=req.url,
data=body_bytes,
headers=headers,
)
SigV4Auth(credentials, "bedrock", region).add_auth(aws_request)
async with httpx.AsyncClient() as client:
response = await client.post(
req.url,
headers={key: str(value) for key, value in aws_request.headers.items()},
content=body_bytes,
)
if not response.is_success:
raise RuntimeError(
f"Bedrock request failed: {response.status_code} {response.text}"
)
payload = response.json()
message = payload["output"]["message"]["content"][0]["text"]
parsed = b.parse.ExtractResume(message)
print(parsed)
asyncio.run(run())

Streaming modular requests are not yet supported for Bedrock. Call b.request (non-streaming) when targeting AWS, and re-sign after any modifications to the body or headers.

AWS Bedrock batch inference example

Similar to OpenAI’s Batch API, you can use the modular API with AWS Bedrock’s batch inference to process large volumes of requests asynchronously.

Prerequisites: You’ll need a S3 bucket, an IAM role with permissions to access S3 and Bedrock, and access to a Bedrock model. See AWS docs for setup.

1

Build a JSONL file from modular requests

Use b.request with an aws-bedrock client to build one JSON object per line. Each line needs a unique recordId; its modelInput is the complete Converse request body that BAML generated.

import json
from baml_client.sync_client import b
def to_jsonl(req):
return json.dumps({
'recordId': req.id,
'modelInput': req.body.json()
})
resumes = [
"John Doe | Software Engineer | BSc in CS",
"Jane Smith | Data Scientist | PhD in Statistics",
]
# Build 100 separate requests so every record ID is unique.
jsonl = '\n'.join(
to_jsonl(b.request.ExtractResume(resume))
for _ in range(50)
for resume in resumes
)
bucket = '<YOUR_S3_BUCKET>'
key = '<YOUR_INPUT_KEY_PREFIX>/batch.jsonl'
output_prefix = '<YOUR_OUTPUT_KEY_PREFIX>/'
region = '<YOUR_REGION>'
2

Upload to S3 and submit the batch job

Write jsonl to the object key you will reference from the batch job, then create a model invocation job pointing at that object and an output prefix in the same bucket. Before the first submission attempt, generate a unique token and persist it with the unchanged job parameters in durable application state. The placeholder below represents that stored value; reload and reuse it whenever an uncertain API response requires retrying the same logical submission.

import time
import boto3
submission_token = '<PERSISTED_UNIQUE_TOKEN>'
try:
boto3.client('s3', region_name=region).put_object(Bucket=bucket, Key=key, Body=jsonl)
print(boto3.client('bedrock', region_name=region).create_model_invocation_job(
jobName=f'baml-batch-{int(time.time() * 1000)}',
modelId='<YOUR_MODEL_ID>',
roleArn='arn:aws:iam::<YOUR_ACCOUNT_ID>:role/<YOUR_BEDROCK_ROLE>',
clientRequestToken=submission_token,
modelInvocationType='Converse',
inputDataConfig={'s3InputDataConfig': {'s3Uri': f's3://{bucket}/{key}', 's3InputFormat': 'JSONL'}},
outputDataConfig={'s3OutputDataConfig': {'s3Uri': f's3://{bucket}/{output_prefix}'}}
))
except Exception as err:
print('Failed to upload batch input or submit Bedrock batch job', err)
raise

Limitations:

  • Record-count quotas vary by model. This example creates 100 records; check the Minimum number of records per batch inference job quota for your model.
  • Jobs are asynchronous. Poll with get_model_invocation_job or monitor state changes with Amazon EventBridge before reading the output.
  • Bedrock does not automatically retry failed individual records. After completion, inspect each output JSONL record for an error instead of modelOutput, then resubmit any records you want to retry in a new job.
  • Client-side retries of the CreateModelInvocationJob API are different from record retries. Reuse the same persisted clientRequestToken and unchanged parameters when the original submission’s outcome is uncertain so the retry cannot create a duplicate job.
  • Bedrock batch inference does not support tool calling or structured output (response_format).

Type Checking

Python

The return type of request.body.json() is Any so you won’t get full type checking in Python when using the SDKs. Here are some workarounds:

1. Using typing.cast

OpenAI
import typing
from openai.types.chat import ChatCompletion
res = typing.cast(ChatCompletion, await client.chat.completions.create(**req.body.json()))

2. Manually setting the arguments

OpenAI
body = req.body.json()
res = await client.chat.completions.create(model=body["model"], messages=body["messages"])

This will preserve the type hints for the OpenAI SDK but it doesn’t work for Anthropic. On the other hand, Gemini SDK / REST API is built in such a way that it basically forces us to use this pattern as seen in the example above.

TypeScript

TypeScript doesn’t have optional parameters like Python, it uses objects instead so you can just cast to the expected type:

OpenAI
import { ChatCompletionCreateParamsNonStreaming } from 'openai/resources';
const res = await client.chat.completions.create(req.body.json() as ChatCompletionCreateParamsNonStreaming)

Streaming

Stream requests and parsing is also supported. Here’s an example using OpenAI SDK:

import typing
from openai import AsyncOpenAI, AsyncStream
from openai.types.chat import ChatCompletionChunk
from baml_client import b
async def run():
client = AsyncOpenAI()
req = await b.stream_request.ExtractResume("John Doe | Software Engineer | BSc in CS")
stream = typing.cast(
AsyncStream[ChatCompletionChunk],
await client.chat.completions.create(**req.body.json())
)
llm_response: list[str] = []
async for chunk in stream:
if len(chunk.choices) > 0 and chunk.choices[0].delta.content is not None:
llm_response.append(chunk.choices[0].delta.content)
# You can parse the partial responses as they come in.
print(b.parse_stream.ExtractResume("".join(llm_response)))

OpenAI Batch API Example

Currently, BAML doesn’t support OpenAI’s Batch API out of the box, but you can use the modular API to build the prompts and parse the responses of batch jobs. Here’s an example:

import asyncio
import json
from openai import AsyncOpenAI
from baml_py import HTTPRequest as BamlHttpRequest
from baml_client import b
from baml_client import types
async def run():
client = AsyncOpenAI()
# Build the batch requests with BAML.
john_req, jane_req = await asyncio.gather(
b.request.ExtractResume("John Doe | Software Engineer | BSc in CS"),
b.request.ExtractResume("Jane Smith | Data Scientist | PhD in Statistics"),
)
# Build the JSONL content.
jsonl = to_openai_jsonl(john_req) + to_openai_jsonl(jane_req)
# Create the batch input file.
batch_input_file = await client.files.create(
file=jsonl.encode("utf-8"),
purpose="batch",
)
# Create the batch.
batch = await client.batches.create(
input_file_id=batch_input_file.id,
endpoint="/v1/chat/completions",
completion_window="24h",
metadata={
"description": "BAML Modular API Python Batch Example"
},
)
# Wait for the batch to complete (exponential backoff).
backoff = 2
attempts = 0
max_attempts = 5
while True:
batch = await client.batches.retrieve(batch.id)
attempts += 1
if batch.status == "completed":
break
if attempts >= max_attempts:
try:
await client.batches.cancel(batch.id)
finally:
raise Exception("Batch failed to complete in time")
await asyncio.sleep(backoff)
back_off *= 2
# Retrieve the batch output file.
output = await client.files.content(batch.output_file_id)
# You can match the batch results using the BAML request IDs.
expected = {
john_req.id: types.Resume(
name="John Doe",
experience=["Software Engineer"],
education=["BSc in CS"]
),
jane_req.id: types.Resume(
name="Jane Smith",
experience=["Data Scientist"],
education=["PhD in Statistics"]
),
}
resumes = {}
for line in output.text.splitlines():
result = json.loads(line)
llm_response = result["response"]["body"]["choices"][0]["message"]["content"]
parsed = b.parse.ExtractResume(llm_response)
resumes[result["custom_id"]] = parsed
print(resumes)
# Should be equal.
assert resumes == expected
def to_openai_jsonl(req: BamlHttpRequest) -> str:
""" Helper that converts a BAML HTTP request to OpenAI JSONL format. """
line = json.dumps({
"custom_id": req.id, # Important for matching the batch results.
"method": "POST",
"url": "/v1/chat/completions",
"body": req.body.json(),
})
return f"{line}\n"