Skip to content

Using the lm15 type system

lm15.types is the provider-independent vocabulary used by LMs, streams, tools, and serialization. The main shape is:

Part -> Message -> Request -> Response

A Part is one typed block of content. A Message is one speaker's list of parts. A Request is the full model call. A Response is the assistant message plus finish reason, usage, and optional provider metadata.

Build messages from parts

For normal prompts, use the role constructors. Plain strings become TextParts.

from lm15.types import Message, Request, image

messages = (
    Message.user("Describe this image."),
    Message.user([
        image(url="https://example.com/cat.png", detail="auto"),
        image(path="./diagram.png"),
    ]),
)

request = Request(
    model="gpt-4.1-mini",
    messages=messages,
    system="Be concise.",
)

Media factories are available for image(), audio(), video(), document(), and binary(). Each media part must have exactly one source: data, url, file_id, or path. Bytes passed to data= are base64-encoded for you.

from lm15.types import audio, binary

clip = audio(data=b"...wav bytes...", media_type="audio/wav")
blob = binary(path="./archive.zip", media_type="application/zip")

raw_bytes = clip.bytes  # works for inline data or path-backed media

Roles enforce valid content

The constructors keep protocol-only parts out of the wrong roles:

  • Message.user(...) and Message.developer(...) are for prompt content.
  • system= accepts text or prompt parts.
  • Message.assistant(...) is for model output, including text, media, thinking traces, tool calls, refusals, and citations.
  • Message.tool(...) may contain only tool results.
from lm15 import tool_result
from lm15.types import Message

tool_msg = Message.tool("call_123", "The current temperature is 19 C.")

several = Message.tool({          # answer several calls in one message
    "call_123": "The current temperature is 19 C.",
})

with_error = Message.tool(        # tool failures go back to the model
    tool_result("call_123", "KeyError: 'city'", name="weather", is_error=True)
)

from lm15 import ImagePart, TextPart
with_image = Message.tool(        # what the tool returned, as it returned it
    tool_result("call_123", (TextPart(text="The chart:"), ImagePart(media_type="image/png", data=png_b64)))
)

A tool result carries whatever the tool returned — text, images, documents — and the adapter puts each part on the wire as the provider's own block (MAP-10). A provider that cannot carry a part raises UnsupportedFeatureError before sending, naming the part, the preset's tool_result_media verdict and the dialects that carry it. It never renders an image as a caption or a placeholder: a request that got through means the model received the content. Measured per provider on 2026-09-07 (lm15-contract/research/tool-result-content/): native on OpenAI Responses, Anthropic, Gemini 3, xAI, Kimi, Meta (Responses), Z.AI; refused on OpenAI Chat Completions, Groq, DeepSeek, Meta Chat, Bedrock Chat. To send an image where the preset refuses, render it to text yourself — that choice is yours, not lm15's.

Configure a request

Universal generation knobs live in Config. Provider-specific options belong in extensions, which must be JSON-compatible.

from lm15.types import Config, FunctionTool, Message, Request, ToolChoice

weather = FunctionTool(
    name="weather",
    description="Get the weather for a city.",
    parameters={
        "type": "object",
        "properties": {"city": {"type": "string"}},
        "required": ["city"],
    },
)

request = Request(
    model="gpt-4.1-mini",
    messages=(Message.user("Weather in Paris?"),),
    tools=(weather,),
    config=Config(
        max_tokens=500,
        temperature=0.2,
        tool_choice=ToolChoice.from_tools(weather),
        extensions={"provider_option": True},
    ),
)

parameters is opaque JSON Schema, always written by you or derived for you — lm15.tool(fn) produces the same FunctionTool from a typed Python function; see Tools from functions.

Reasoning is explicit and optional: Config(reasoning=None) means "the model decides"; Config(reasoning=Reasoning(effort="off")) explicitly turns reasoning off. effort is required, so a bare Reasoning() is a TypeError — there is no accidental off.

from lm15.types import Config, Reasoning

config = Config(reasoning=Reasoning(effort="medium", thinking_budget=1024))

Read responses by variant

Message.text is strict: it is only set when the message is pure text. Response.text is slightly more forgiving on the response's own message — citation and thinking parts are metadata around the visible answer, so it still joins the TextParts when only those are mixed in. For anything richer, use message.first() and message.parts_of() with concrete part classes.

from lm15.types import CitationPart, ImagePart, TextPart

text = response.text  # str | None
first_image = response.message.first(ImagePart)
all_citations = response.message.parts_of(CitationPart)
all_text_parts = response.message.parts_of(TextPart)

For JSON responses, response.parse_json() parses exact JSON text and raises a helpful error on failure. response.json returns the parsed value or None.

Streaming uses typed events

Streams are represented as StreamEvents:

  • StreamStartEvent
  • StreamDeltaEvent(delta=...)
  • StreamEndEvent
  • StreamErrorEvent

Deltas are also typed (TextDelta, ImageDelta, ToolCallDelta, etc.). The helpers in lm15.result convert between complete responses and streams.

from lm15.result import materialize_response, response_to_events

for event in response_to_events(response):
    ...

response = materialize_response(events, request)

Not every part is streamable. Text, thinking, image, audio, tool calls, and citations have delta variants. Video, document, binary, tool result, and refusal parts have no delta representation; they can appear in prompts or final materialized messages but cannot be emitted incrementally.

Serialize when crossing process boundaries

Use lm15.serde for the canonical JSON-shaped dictionaries:

from lm15.serde import request_from_dict, request_to_dict

payload = request_to_dict(request)
request2 = request_from_dict(payload)

Canonical JSON is the portable interchange format used by the conformance fixtures and future language ports.

Validation model

The dataclasses are frozen and slotted. Constructors validate the invariants that make objects meaningful: required identities, legal literal values, role/content compatibility, one media source per media part, non-negative token counts, and strict JSON-compatible config/metadata. LMs should normalize provider quirks before constructing these types.