Skip to main content

🪄 callm

A small, type-safe Python interface to the chat models, built on pydantic-ai.

callm is the contract, not the transport. Five providers reach you through one ChatModel: called for a turn, streamed for incremental results, and the same either way. The wire protocols underneath are pydantic-ai's, which is why there is so little here to go wrong.

Features:

  • One interface across OpenAI, Codex and Azure OpenAI
  • Type-safe structured output with Pydantic
  • Tool calling
  • Async streaming, ending in the same response a call returns
  • Images, reasoning traces and cache-aware token usage
  • Automatic retries for transient failures, with per-retry callbacks

Contents

Installation

pip install pycallm

This covers OpenAI, Azure OpenAI and Codex. The Codex WebSocket transport needs one more package:

pip install pycallm[websocket]

Quick start

import asyncio
from callm import ChatOpenAI, SystemMessage, UserMessage

async def main():
    async with ChatOpenAI("gpt-6-sol") as model:
        response = await model.call([
            SystemMessage(content="You are a helpful assistant."),
            UserMessage(content="What is 2+2?"),
        ])

    print(response.completion)          # "2 + 2 equals 4."
    print(response.usage.total_tokens)  # 29

asyncio.run(main())

The contract

Everything public lives in callm.messages and callm.base, and every provider speaks exactly it.

Messages

Four message types, all frozen, so a history can be shared between requests without one of them editing another's:

from callm import SystemMessage, UserMessage, AssistantMessage, ToolResultMessage

messages = [
    SystemMessage(content="You are a Python expert."),
    UserMessage(content="How do I read a file?"),
    AssistantMessage(content="Use open() with a context manager."),
    UserMessage(content="Show me an example."),
]

A SystemMessage is carried as the request's instructions rather than as a turn in the history, which is what every provider actually wants.

Calling

call runs one turn and returns it whole:

response = await model.call(messages)

A ModelResponse carries:

Field What it is
completion the text, or the parsed object when output_format was given (None while a turn with tools is still calling them)
thinking the reasoning trace, when the model exposed one
tool_calls what the model wants run before it can finish
usage input, output and cache token counts
finish_reason why the model stopped
provider_state the provider's own turn, for replaying it verbatim

response.as_assistant_message() turns a turn into the history entry for the next request, provider_state included — which is what keeps a reasoning model from losing its train of thought across a tool round-trip.

Streaming

A stream yields deltas as they arrive and ends with exactly one ModelResponse, the same value call would have returned. Tool calls are never streamed half-built: each one arrives once its arguments are complete.

from callm import ModelEventType

async for event in model.stream(messages):
    match event.type:
        case ModelEventType.TEXT_DELTA:
            print(event.delta, end="", flush=True)
        case ModelEventType.THINKING_DELTA:
            ...  # reasoning, kept separate from the answer
        case ModelEventType.TOOL_CALL:
            print(event.tool_call.name)
        case ModelEventType.RESPONSE:
            print(event.usage.total_tokens)

Structured output

Hand call a Pydantic model and get one back:

from pydantic import BaseModel

class Recipe(BaseModel):
    name: str
    minutes: int
    ingredients: list[str]

response = await model.call(messages, output_format=Recipe)
response.completion.ingredients  # list[str]

Every provider does this through a tool call, since that is the one shape all of them speak. An answer that does not parse raises ModelBehaviorError rather than arriving as something it is not.

Tools

A tool is a name, a description and a JSON schema. Running the calls the model asks for is up to you:

from callm import ModelTool, ToolResultMessage

search_web = ModelTool(
    name="search_web",
    description="Search the web for information.",
    parameters={
        "type": "object",
        "properties": {"query": {"type": "string", "description": "What to look for"}},
        "required": ["query"],
    },
)

messages = [UserMessage(content="Look up callm and summarise it.")]

while True:
    response = await model.call(messages, tools=[search_web])
    messages.append(response.as_assistant_message())
    if not response.tool_calls:
        break
    for call in response.tool_calls:
        query = call.parsed_arguments["query"]
        messages.append(ToolResultMessage(
            tool_call_id=call.id,
            tool_name=call.name,
            content=f"results for {query}",
        ))

tool_choice takes "auto", "required" or "none".

Images

from callm import ImageUrl, UserMessage

UserMessage(content=(
    "What's in this image?",
    ImageUrl(url="https://example.com/photo.jpg", detail="high"),
))

A data: URI works the same way and is sent as bytes.

Usage and reasoning

Usage reports input_tokens, output_tokens, cache_read_tokens, cache_write_tokens and a total_tokens property. Every provider fills the same fields; a counter a provider does not report stays zero.

Errors and retries

Provider failures arrive as callm errors, whichever SDK raised them:

Error Raised when
AuthenticationError credentials rejected (401, 403)
CredentialsUnavailableError credentials missing or unusable, a new login is needed
RateLimitError temporary 429 — retryable
RetryableError 5xx, 408, 409, 425, transport failures
OutOfCreditsError quota or billing exhausted
ContextLengthExceededError the input did not fit
ModelBehaviorError the answer did not fit the shape it was asked for
ProviderError another nonretryable provider HTTP error
ResponseInterruptedError a Codex WebSocket response started, then the connection broke; never replayed automatically

Retryable failures are retried with exponential backoff, honouring Retry-After when the provider sends one. A stream is only retried while nothing has been emitted yet, so output is never replayed. on_retry must be an async callable; it receives the delay in seconds and one-based attempt numbers. The SDK's own retries are disabled so the callback sees every retry.

async def log_retry(event):
    print(f"attempt {event.failed_attempt}/{event.max_attempts} failed, "
          f"retrying in {event.delay:.1f}s: {event.error.code}")

model = ChatOpenAI("gpt-6-sol", max_retries=3, on_retry=log_retry)

For UI messages, use event.error.user_message and event.delay. Use event.error.code to localize the message. Exception strings can include raw provider details and belong in private diagnostics rather than UI text.

Providers

Every constructor takes the model name first and falls back to the usual environment variable for credentials.

from callm import (
    ChatOpenAI,            # OPENAI_API_KEY
    ChatOpenAIResponses,   # OPENAI_API_KEY — the Responses API
    ChatAzureOpenAI,       # AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT
    ChatAzureOpenAIResponses,
    ChatCodex,             # a ChatGPT subscription
)

model = ChatOpenAI("gpt-6-sol", api_key="sk-...", base_url="https://...")
model = ChatAzureOpenAI("my-deployment", api_version="2024-10-01")

Reasoning models. ChatOpenAIResponses (and ChatCodex, and ChatAzureOpenAIResponses) take reasoning_effort — "none", "minimal", "low", "medium", "high", "xhigh" or "max" — and reasoning_summary. Which levels a model accepts differs, and an unsupported one comes back as a request error. Prefer the Responses API over ChatOpenAI for these models: it carries reasoning state between turns.

Codex. A reverse-engineered endpoint that authenticates with a ChatGPT subscription rather than an API key; OpenAI neither documents nor supports it. If the Codex CLI is logged in, its session is borrowed:

model = ChatCodex("gpt-6-sol", reasoning_effort="high")

ChatCodex uses HTTP by default. Set transport="websocket" to reuse a WebSocket connection across turns; this needs the websocket extra. If opening that connection or starting a response fails, it retries the request over HTTP. A connection lost after a response starts is reported without replaying the request.

For a conversation where the next user message has not arrived yet, prepare() can send the existing context over the WebSocket without generating an answer. The next matching turn reuses that prepared context:

from callm import ChatCodex, Message, SystemMessage, UserMessage

async with ChatCodex("gpt-6-sol", transport="websocket") as model:
    history: list[Message] = [SystemMessage(content="Answer briefly.")]
    await model.prepare(history)
    history.append(UserMessage(content="Name one European capital."))
    answer = await model.call(history)

Pass the same tools, tool_choice, and output_format to prepare() as to the next call when using them. If the history or connection changes, the next request sends the full history. With transport="http", prepare() is a no-op. See the WebSocket example for multiple turns. Pass an async on_transport_fallback callback to receive a TransportFallbackEvent with phase ("prepare", "call", or "stream") and reason. A failed preparation reports its error; call() and stream() report when they actually switch to HTTP. The example prints these events.

This reads ~/.codex/auth.json (honouring CODEX_HOME) but never writes it, so refreshed tokens last only as long as the process. To keep them, pass a credential_source — any OpenAICodexCredentialSource, as described in pydantic-ai's docs. A missing or unusable login raises CredentialsUnavailableError.

Model settings

Shared pydantic-ai settings are named, keyword-only parameters on every model constructor. The IDE can show their types and defaults:

model = ChatOpenAI(
    "gpt-6-sol",
    reasoning_effort="none",
    max_tokens=1000,
    temperature=0.7,
    stop_sequences=["\n\n"],
    timeout=60.0,
    max_retries=2,
    extra_headers={"X-Tenant": "acme"},
)

A setting's availability depends on the provider and model. Other provider-specific options are passed to pydantic-ai as model settings:

model = ChatOpenAIResponses("gpt-6-sol", openai_text_verbosity="low")

See pydantic-ai's model settings for the full list.

Credits

Built on pydantic-ai. Inspired by LangChain and browser-use.

License

MIT

Release files for pycallm 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pycallm 0.1.0
File Size Uploaded
pycallm-0.1.0.tar.gz 40.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pycallm 0.1.0
File Interpreter ABI Platform
pycallm-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 69.3 kB

Release files / pycallm-0.1.0.tar.gz

Download URL pycallm-0.1.0.tar.gz
Size 40.9 kB
Tags Source
SHA-256 checksum
How to use checksums
77a7db6054fa242332689dcc0d26cb291075c96a7d9bef5ff8d15d05176286bc
BLAKE2b-256 checksum
How to use checksums
8491b4f40535da4edcdb0c6e14597aeb6d548fad015afa8c7d7550f7d695cd7f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.2

Release files / pycallm-0.1.0-py3-none-any.whl

Download URL pycallm-0.1.0-py3-none-any.whl
Size 28.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a17132479aad08844819ebcb2fdb8684747f6e5700be87db45c10814bd12b196
BLAKE2b-256 checksum
How to use checksums
6b651823259c23c9cb68178634ccd03f5123534caa588e86363a908350b4a497
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.9.2

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page