flowchart LR
I["LF AI Platform Input"] --> IA["Input Adapter"]
IA -- "Request Body" --> ME["Model Endpoint"]
ME -- "Response Body" --> OA["Output Adapter"]
OA --> O["LF AI Platform Output"]
classDef adapter fill:#e8f0fe,stroke:#1a73e8,stroke-width:2px,color:#174ea6
classDef payload fill:#f1f3f4,stroke:#9aa0a6,stroke-width:2px,color:#3c4043
class IA,OA adapter
class I,ME,O payload
Create a Model Adapter
Your endpoint speaks its own wire format. A task does not: its solver produces an LF AI Platform model input - a list of messages - and its scorers expect an LF AI Platform model output. A model adapter is the pair of Jinja templates that closes that gap: one renders the request body your endpoint expects, the other parses the endpoint’s response back into the LF AI Platform format.
Adapters are configuration, not code you deploy: two templates, registered once, then referenced by key from any number of models. There is no proxy service to run in front of your endpoint.
Do You Need a Custom Adapter?
Often not - LF AI Platform ships adapters for the most common wire formats. Check this table before writing your own.
| Your situation | What to do |
|---|---|
| The endpoint is OpenAI-compatible (chat completions, responses, or embeddings). | Use a built-in adapter: latticeflow$openai_chat_completion, latticeflow$openai_responses, latticeflow$openai_embeddings. |
| The endpoint already returns LF AI Platform model I/O, for example because a custom inference snippet builds it. | Use latticeflow$identity, which passes the body through unchanged. It is also the default. |
The endpoint uses its own field names, wraps the payload, or does not take a messages array. |
Write a custom adapter - continue with this guide. |
| The response carries data you want in the evaluation, such as token usage, retrieved citations, or a refusal field. | Write a custom adapter - continue with this guide. |
| The endpoint is stateful and expects a conversation identifier to be threaded back on every turn. | Write a custom adapter, following Integrate Stateful Model Endpoint. |
| Inference needs several chained calls, an SDK, or a custom protocol. | Templates are not enough - see Integrate Custom Model. |
Pull a built-in adapter by provider and key, and skip the rest of this page:
lf add model-adapter -p latticeflow/openai_chat_completionWalkthrough: An OpenAI Chat Completion Adapter
The rest of this guide builds an adapter for the OpenAI chat completion API from scratch, and points a model at it.
This exact adapter already ships as latticeflow$openai_chat_completion - for a real OpenAI endpoint, use the built-in. It is rebuilt here because its wire format is public and easy to check against, which keeps the focus on how the templates work. Every step applies unchanged to an endpoint that has no built-in adapter.
The complete example is available in the model-openai-chat-completion integration guide.
Step 1: Inspect the Wire Format
Start from the endpoint, not from LF AI Platform. Call it directly and write down the two payloads the adapter has to produce and consume.
curl https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4.1-nano",
"messages": [{"role": "user", "content": "Hello"}]
}'Response (fields the adapter uses)
{
"choices": [
{"message": {"role": "assistant", "content": "Hi! How can I help?", "refusal": null}}
],
"usage": {"prompt_tokens": 8, "completion_tokens": 7}
}The input template must produce the request body; the output template must consume the response body. Everything else follows from these two shapes.
Step 2: Write the Input Template
The input template receives the LF Platform model input as input, the model’s metadata as model_info, and renders the request body as a string. See Template Contract for the full list of available variables.
Take the request body apart field by field.
The endpoint identifies the model in the body. Read it from the model that uses the adapter, so the same adapter serves every OpenAI model.
model_adapters/openai_chat_completion_input.jinja
{ "model": "{{ model_info.model_key }}", ... }Render the messages.
input.messagesholds the whole conversation the solver has produced so far. Pass every value throughtojson- message content contains quotes and newlines, and interpolating it raw produces invalid JSON.model_adapters/openai_chat_completion_input.jinja
{ ... "messages": [ {% for message in input.messages %} { "role": "{{ message.role }}", "content": {{ message.content | tojson }} }{% if not loop.last %},{% endif %} {% endfor %} ] ... }Forward the optional fields.
input.response_formatis set when the task requests structured output, andkwargscarries additional per-call configuration. Both are usually absent, so guard them.model_adapters/openai_chat_completion_input.jinja
{ ... {% if input.response_format is defined and input.response_format is not none %} ,"response_format": {{ input.response_format | tojson }} {% endif %} {% for key, value in kwargs.items() %} ,"{{ key }}": {{ value | tojson }} {% endfor %} }
model_adapters/openai_chat_completion_input.jinja
{
"model": "{{ model_info.model_key }}",
"messages": [
{% for message in input.messages %}
{
"role": "{{ message.role }}",
"content": {{ message.content | tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
{% if input.response_format is defined and input.response_format is not none %}
,"response_format": {{ input.response_format | tojson }}
{% endif %}
{% for key, value in kwargs.items() %}
,"{{ key }}": {{ value | tojson }}
{% endfor %}
}
Step 3: Write the Output Template
The output template receives the raw response body as a string and the HTTP status_code, and renders LF AI Platform model output as a JSON string.
Handle failures first. The template runs for every response, including error ones, so branch on the status code before attempting to parse a body that may not have the expected shape at all.
model_adapters/openai_chat_completion_output.jinja
{% if status_code != 200 %} {"error": "A non-200 status code ({{ status_code }}) was returned by the model."} {% else %} ... {% endif %}Parse the body and map the choices.
bodyarrives as a string, so parse it withfromjsonfirst. Fields that the provider may omit or set tonullneed a default - LF AI Platform output requiresroleandcontentto be strings.model_adapters/openai_chat_completion_output.jinja
{% set response = body | fromjson %} { "choices": [ {% for choice in response.choices %} { "message": { "role": {{ (choice.message.role if choice.message.role is defined and choice.message.role is not none else '') | tojson }}, "content": {{ (choice.message.content if choice.message.content is defined and choice.message.content is not none else '') | tojson }} {% if choice.message.refusal is defined and choice.message.refusal is not none %} ,"refusal": {{ choice.message.refusal | tojson }} {% endif %} } }{% if not loop.last %},{% endif %} {% endfor %} ] }Map token usage. Every provider reports usage under its own field names; LF AI Platform expects
num_prompt_tokensandnum_completion_tokens. Without this block, the evaluation reports no token counts - see Token Usage Tracking.model_adapters/openai_chat_completion_output.jinja
{% if response.usage is defined and response.usage is not none %} ,"usage": { "num_prompt_tokens": {{ response.usage.prompt_tokens }}, "num_completion_tokens": {{ response.usage.completion_tokens }} } {% endif %}
model_adapters/openai_chat_completion_output.jinja
{% if status_code != 200 %}
{"error": "A non-200 status code ({{ status_code }}) was returned by the model."}
{% else %}
{% set response = body | fromjson %}
{
"choices": [
{% for choice in response.choices %}
{
"message": {
"role": {{ (choice.message.role if choice.message.role is defined and choice.message.role is not none else '') | tojson }},
"content": {{ (choice.message.content if choice.message.content is defined and choice.message.content is not none else '') | tojson }}
{% if choice.message.refusal is defined and choice.message.refusal is not none %}
,"refusal": {{ choice.message.refusal | tojson }}
{% endif %}
}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
{% if response.usage is defined and response.usage is not none %}
,"usage": {
"num_prompt_tokens": {{ response.usage.prompt_tokens }},
"num_completion_tokens": {{ response.usage.completion_tokens }}
}
{% endif %}
}
{% endif %}
Step 4: Register the Adapter
Declare the adapter in a YAML file and pull the two templates in with !include, so they stay in their own files and keep syntax highlighting.
model_adapters/openai_chat_completion.yaml
display_name: "OpenAI Chat Completion"
key: "openai-chat-completion"
description: "OpenAI chat completion API model adapter."
process_input:
language: "jinja"
source_code: !include "./openai_chat_completion_input.jinja"
process_output:
language: "jinja"
source_code: !include "./openai_chat_completion_output.jinja"An adapter is tied to a wire format, not to an ML task - there is no task field. language accepts only "jinja".
Register it. If no adapter with that key exists, it is created; otherwise it is updated when the definition has changed.
lf add model-adapter -f 'model_adapters/openai_chat_completion.yaml' # A single adapter.
lf add model-adapter -f 'model_adapters/*.yaml' # Every adapter in a directory.Every property is documented in the Model Adapters CLI reference, and the remaining commands (lf list, lf export, lf delete) in the CLI reference.
Step 5: Connect a Model and Test
A model references the adapter by key under config.adapter.key.
models/openai_gpt_4-1-nano.yaml
display_name: "OpenAI GPT-4.1 Nano"
key: "openai-gpt-4-1-nano"
task: "chat_completion"
rate_limit: 60
config:
connection_type: "custom_connection"
adapter:
key: "openai-chat-completion"
url: "https://api.openai.com/v1/chat/completions"
api_key: $OPENAI_API_KEY
model_key: "gpt-4.1-nano"lf add model -f 'models/openai_gpt_4-1-nano.yaml'
lf test model openai-gpt-4-1-nanolf test model runs one request through the whole pipeline and prints each phase separately: the request body your input template rendered, the raw endpoint response, and the LF AI Platform output your output template produced. That is the loop to iterate in while writing templates - see Testing models, which also covers the typical template failures.
Jinja Filters
In addition to Jinja’s built-in filters, LF AI Platform provides the following.
| Filter | Description | Example |
|---|---|---|
fromjson |
Parses a JSON string, so nested data can be accessed as body.choices. |
{% set body = body | fromjson %} |
json_escape |
Makes a string safe to insert into a JSON string, escaping quotes and newlines. | {{ model_info.model_key | json_escape }} |
zip |
Zips two sequences, as Python’s zip does. |
{% for name, value in names | zip(values) %} |
merge |
Merges dictionaries into one. | {{ defaults | merge(overrides) | tojson }} |
regex_replace |
Replaces every match of a regular expression. | {{ text | regex_replace("\\s+", " ") }} |
The do and loopcontrols extensions are enabled, so {% do ... %}, {% break %} and {% continue %} are available inside templates.