Using a provider LM¶
Provider LMs translate between the provider-independent lm15.types
vocabulary and one provider's HTTP or WebSocket API. They are the layer above
lm15.transports and below higher-level client/result helpers.
The built-in LMs are:
from lm15.providers import (
AnthropicLM, ClaudeCodeLM, GeminiLM, OpenAIChatLM, OpenAICodexLM,
OpenAILM, XaiLM,
)
OpenAIChatLM speaks the Chat Completions dialect (and, via compat
presets, Groq, OpenRouter, ollama, vLLM, and SGLang); ClaudeCodeLM,
OpenAICodexLM, and XaiLM add subscription-credential handling on
top of the Anthropic and Chat Completions dialects. Each LM exposes
the same endpoint-oriented surface:
complete(Request) -> Response
stream(Request) -> Iterator[StreamEvent]
live(LiveConfig) -> LiveSession
file_upload(FileUploadRequest) -> FileInfo
file_get(file_id) -> FileInfo
file_list(limit, cursor) -> FilePage
file_delete(file_id) -> None
file_download(file_id) -> bytes
batch(requests) -> BatchJob # plus batch_job(id), batches()
image_generate(ImageGenerationRequest) -> ImageGenerationResponse
speech_generate(SpeechGenerationRequest) -> SpeechGenerationResponse
video_generate(VideoGenerationRequest) -> VideoJob # plus video_job(id), video_jobs()
list_models() -> tuple[ModelInfo, ...]
Unsupported endpoints raise UnsupportedFeatureError.
Create an LM¶
LMs need an API key. If you do not pass a transport, the LM creates a
StdlibTransport by default.
from lm15.providers import OpenAILM
lm = OpenAILM(api_key="sk-...")
Use the LM as a context manager when you want its default transport closed for you. If you need to share a connection pool across several LMs, pass an explicit long-lived transport.
from lm15.providers import AnthropicLM, OpenAILM
from lm15.transports import StdlibTransport
with OpenAILM(api_key="sk-...") as lm:
...
with StdlibTransport(max_connections=100) as transport:
openai = OpenAILM(api_key="sk-...", transport=transport)
anthropic = AnthropicLM(api_key="sk-ant-...", transport=transport)
...
Local and OpenAI-compatible endpoints¶
Name the server; the preset supplies its wire policy and its address:
from lm15.providers import OpenAILM
lm = OpenAILM(api_key="ollama", compat="ollama") # http://localhost:11434/v1
lm = OpenAILM(api_key="ollama", compat="ollama", base_url="http://gpu-box:11434/v1")
A preset name lm15 has no address for in the chosen dialect is refused with
NotConfiguredError until you pass base_url=; it is never sent to the
OpenAI cloud. (ProviderProfile / OpenAILM.from_profile, the older way to
say the same, are deprecated and removed in 1.0.0.)
See Using model profiles and compatibility policies for model metadata, registries, presets, and request-level escape hatches.
Make a complete call¶
LMs consume lm15.types.Request and return lm15.types.Response.
from lm15.providers import OpenAILM
from lm15.types import Config, Message, Request
with OpenAILM(api_key="sk-...") as lm:
request = Request(
model="gpt-4.1-mini",
messages=(Message.user("Write one sentence about TCP."),),
config=Config(max_tokens=100, temperature=0.2),
)
response = lm.complete(request)
print(response.text)
print(response.usage.total_tokens)
response.provider_data contains the raw provider response dictionary when the
LM has one. Keep application logic on the typed fields when possible and
use provider data only for provider-specific diagnostics or features.
Stream typed events¶
lm.stream() yields typed stream events, not provider JSON dictionaries.
from lm15.types import StreamDeltaEvent, StreamEndEvent, TextDelta
for event in lm.stream(request):
if isinstance(event, StreamDeltaEvent) and isinstance(event.delta, TextDelta):
print(event.delta.text, end="", flush=True)
elif isinstance(event, StreamEndEvent):
print("\nfinish:", event.finish_reason)
Stream errors are emitted as StreamErrorEvent when the provider reports an
in-band streaming error. HTTP errors before the stream starts are raised as
provider exceptions such as AuthError, RateLimitError, or ServerError.
Tools¶
Function tools are provider-independent. The LM maps them to the provider's wire shape.
from lm15.types import Config, FunctionTool, Message, Request, ToolChoice
weather = FunctionTool(
name="weather",
description="Get 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(tool_choice=ToolChoice.from_tools(weather)),
)
response = lm.complete(request)
for call in response.tool_calls:
print(call.id, call.name, call.input)
Tool results go back as Message.tool(...) in a follow-up Request.
from lm15.types import Message
follow_up = Request(
model=request.model,
messages=request.messages + (
response.message,
Message.tool(response.tool_calls[0].id, "19 C and cloudy"),
),
tools=request.tools,
config=request.config,
)
Builtin tools are declared with BuiltinTool. The LM maps canonical names
like web_search and code_execution to each provider's current native tool
identifier where one is known.
from lm15.types import BuiltinTool
request = Request(
model="gpt-4.1-mini",
messages=(Message.user("Search for the latest release notes."),),
tools=(BuiltinTool("web_search"),),
)
Provider-specific options¶
Universal knobs live in Config. Provider-specific options go in
Config.extensions and are passed through by LMs after reserving a few
lm15 keys such as prompt_caching, output, and transport.
from lm15.types import Config, Reasoning
request = Request(
model="claude-3-7-sonnet-latest",
messages=(Message.user("Think carefully, then answer."),),
config=Config(
max_tokens=1000,
reasoning=Reasoning(effort="medium", thinking_budget=1024),
extensions={
"prompt_caching": True,
"metadata": {"user_id": "user-123"},
},
),
)
Common extension keys used by built-in LMs:
prompt_caching: enables LM-specific prompt/cache wiring where supported.output: set to"image"or"audio"for providers that use chat/generate endpoints for non-text output.transport: set to"live","websocket", or"ws"to force live WebSocket completion for OpenAI/Gemini live-capable models.
Files, images, and audio¶
Endpoint-specific request types also use the same LM surface. A stored
file's id goes straight into a media Part's file_id; the provider is
the system of record, so a lost id is recovered with file_list, never
with client-side bookkeeping.
from lm15.types import FileUploadRequest
info = lm.file_upload(
FileUploadRequest(filename="notes.txt", bytes_data=b"hello", media_type="text/plain")
)
info = lm.file_wait_ready(info.id) # only Gemini uploads are ever pending
lm.file_list(limit=10) # the queue remembers
lm.file_delete(info.id) # returning without an exception IS the confirmation
Download works only for files the provider marks downloadable (for example tool-generated output); other files raise the provider's typed refusal.
Generated media responses return typed media parts.
from lm15.types import ImageGenerationRequest
result = lm.image_generate(
ImageGenerationRequest(model="gpt-image-1", prompt="A small red cube")
)
image_bytes = result.images[0].bytes
Error normalization¶
LMs convert provider-specific error shapes into the canonical lm15 error hierarchy:
from lm15.errors import AuthError, RateLimitError, ServerError
try:
lm.complete(request)
except AuthError:
print("check API key")
except RateLimitError:
print("retry later")
except ServerError:
print("provider-side issue")
The structured stream equivalent is ErrorDetail, carried by
StreamErrorEvent.
Inspect the exact HTTP request¶
For tests and fixtures, call build_request() directly. It returns a
transport-level lm15.transports.Request with bytes ready to send.
http_request = lm.build_request(request, stream=False)
print(http_request.method)
print(http_request.url)
print(http_request.headers)
print(http_request.body.decode("utf-8"))
This is the easiest way to snapshot provider wire shapes without doing network I/O.
Test an LM with a fake transport¶
A provider LM only needs a transport object with stream(request) returning
a context-managed response — and lm15 ships that double in lm15.testing,
so you do not write it yourself:
from lm15.testing import FakeResponse, FakeTransport
fake = FakeTransport([FakeResponse(200, b'{"id":"r","output":[]}')])
lm = OpenAILM(api_key="test", transport=fake)
The fake records every TransportRequest in fake.requests, so one
fixture asserts both behavior and wire format. lm15.testing.FakeLM is
the canonical-level double (script Response objects, no wire JSON) —
see recipe 17.
Implement a new provider¶
Subclass BaseProviderLM and implement the four core translation methods:
from lm15.providers import BaseProviderLM
class MyLM(BaseProviderLM):
provider = "my-provider"
def build_request(self, request, stream):
... # Request -> lm15.transports.TransportRequest
def parse_response(self, request, response):
... # buffered HTTP response -> lm15.types.Response
def parse_stream_events(self, request, raw_event):
... # SSEEvent -> iterator of StreamEvent
def normalize_error(self, status, body):
... # HTTP error -> ProviderError
Guidelines:
- Normalize provider quirks before constructing
lm15.typesobjects. - Return typed parts and deltas, never provider dictionaries.
- Put raw provider telemetry in
provider_data. - Keep provider-only request options in
Config.extensions. - Let the transport handle sockets, TLS, pooling, and streaming bytes.