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.

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

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_completion

Walkthrough: 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.

Note

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.

  1. 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 }}",
        ...
    }
  2. Render the messages. input.messages holds the whole conversation the solver has produced so far. Pass every value through tojson - 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 %}
        ]
        ...
    }
  3. Forward the optional fields. input.response_format is set when the task requests structured output, and kwargs carries 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.

  1. 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 %}
  2. Parse the body and map the choices. body arrives as a string, so parse it with fromjson first. Fields that the provider may omit or set to null need a default - LF AI Platform output requires role and content to 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 %}
        ]
    }
  3. Map token usage. Every provider reports usage under its own field names; LF AI Platform expects num_prompt_tokens and num_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-nano

lf 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.

Next Steps