Repository: pydantic/pydantic-ai
Pull request #1599: Add `direct` public API
Author: login: samuelcolvin
Created: 2025-04-25T22:44:21Z | Merged: 2025-05-13T13:31:31Z

This introduces a new lower level API:

> The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.


Diff:
diff --git a/docs/api/direct.md b/docs/api/direct.md
new file mode 100644
index 0000000000..66462ec6c3
--- /dev/null
+++ b/docs/api/direct.md
@@ -0,0 +1,3 @@
+# `pydantic_ai.direct`
+
+::: pydantic_ai.direct
diff --git a/docs/direct.md b/docs/direct.md
new file mode 100644
index 0000000000..6c9dc26887
--- /dev/null
+++ b/docs/direct.md
@@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a non-streamed synchronous request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry or Logfire Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')],
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+_(This example is complete, it can be run "as is")_
+
+You can also enable OpenTelemetry on a per call basis:
+
+```python {title="direct_instrumented.py" hl_lines="1 6 12"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')],
+    instrument=True
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+See [Debugging and Monitoring](logfire.md) for more details, including how to instrument with plain OpenTelemetry without Logfire.
diff --git a/mkdocs.yml b/mkdocs.yml
index 4c134b24d2..410b13ce48 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -38,6 +38,7 @@ nav:
       - graph.md
       - evals.md
       - input.md
+      - direct.md
       - MCP:
           - mcp/index.md
           - mcp/client.md
@@ -68,6 +69,7 @@ nav:
       - api/usage.md
       - api/mcp.md
       - api/format_as_xml.md
+      - api/direct.md
       - api/models/base.md
       - api/models/openai.md
       - api/models/anthropic.md
diff --git a/pydantic_ai_slim/pydantic_ai/agent.py b/pydantic_ai_slim/pydantic_ai/agent.py
index 8613244919..0d17c04a0c 100644
--- a/pydantic_ai_slim/pydantic_ai/agent.py
+++ b/pydantic_ai_slim/pydantic_ai/agent.py
@@ -28,7 +28,7 @@
     result,
     usage as _usage,
 )
-from .models.instrumented import InstrumentationSettings, InstrumentedModel
+from .models.instrumented import InstrumentationSettings, InstrumentedModel, instrument_model
 from .result import FinalResult, OutputDataT, StreamedRunResult, ToolOutput
 from .settings import ModelSettings, merge_model_settings
 from .tools import (
@@ -108,7 +108,7 @@ class Agent(Generic[AgentDepsT, OutputDataT]):
     model: models.Model | models.KnownModelName | str | None
     """The default model configured for this agent.
 
-    We allow str here since the actual list of allowed models changes frequently.
+    We allow `str` here since the actual list of allowed models changes frequently.
     """
 
     name: str | None
@@ -233,7 +233,7 @@ def __init__(
 
         Args:
             model: The default model to use for this agent, if not provide,
-                you must provide the model when calling it. We allow str here since the actual list of allowed models changes frequently.
+                you must provide the model when calling it. We allow `str` here since the actual list of allowed models changes frequently.
             output_type: The type of the output data, used to validate the data returned by the model,
                 defaults to `str`.
             instructions: Instructions to use for this agent, you can also register instructions via a function with
@@ -1582,13 +1582,7 @@ def _get_model(self, model: models.Model | models.KnownModelName | str | None) -
         if instrument is None:
             instrument = self._instrument_default
 
-        if instrument and not isinstance(model_, InstrumentedModel):
-            if instrument is True:
-                instrument = InstrumentationSettings()
-
-            model_ = InstrumentedModel(model_, instrument)
-
-        return model_
+        return instrument_model(model_, instrument)
 
     def _get_deps(self: Agent[T, OutputDataT], deps: T) -> T:
         """Get deps for a run.
diff --git a/pydantic_ai_slim/pydantic_ai/direct.py b/pydantic_ai_slim/pydantic_ai/direct.py
new file mode 100644
index 0000000000..71f02e7204
--- /dev/null
+++ b/pydantic_ai_slim/pydantic_ai/direct.py
@@ -0,0 +1,215 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from contextlib import AbstractAsyncContextManager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return await model_instance.request(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def model_request_sync(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a Synchronous, non-streamed request to a model.
+
+    This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
+    `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
+
+    ```py title="model_request_sync_example.py"
+    from pydantic_ai.direct import model_request_sync
+    from pydantic_ai.messages import ModelRequest
+
+    model_response = model_request_sync(
+        'anthropic:claude-3-5-haiku-latest',
+        [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+    )
+    print(model_response)
+    '''
+    ModelResponse(
+        parts=[TextPart(content='Paris', part_kind='text')],
+        usage=Usage(
+            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
+        ),
+        model_name='claude-3-5-haiku-latest',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    return _get_event_loop().run_until_complete(
+        model_request(
+            model,
+            messages,
+            model_settings=model_settings,
+            model_request_parameters=model_request_parameters,
+            instrument=instrument,
+        )
+    )
+
+
+def model_request_stream(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> AbstractAsyncContextManager[models.StreamedResponse]:
+    """Make a streamed async request to a model.
+
+    ```py {title="model_request_stream_example.py"}
+
+    from pydantic_ai.direct import model_request_stream
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        messages = [ModelRequest.user_text_prompt('Who was Albert Einstein?')]  # (1)!
+        async with model_request_stream( 'openai:gpt-4.1-mini', messages) as stream:
+            chunks = []
+            async for chunk in stream:
+                chunks.append(chunk)
+            print(chunks)
+            '''
+            [
+                PartStartEvent(
+                    index=0,
+                    part=TextPart(content='Albert Einstein was ', part_kind='text'),
+                    event_kind='part_start',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(
+                        content_delta='a German-born theoretical ', part_delta_kind='text'
+                    ),
+                    event_kind='part_delta',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(content_delta='physicist.', part_delta_kind='text'),
+                    event_kind='part_delta',
+                ),
+            ]
+            '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        A [stream response][pydantic_ai.models.StreamedResponse] async context manager.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return model_instance.request_stream(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def _prepare_model(
+    model: models.Model | models.KnownModelName | str,
+    instrument: instrumented_models.InstrumentationSettings | bool | None,
+) -> models.Model:
+    model_instance = models.infer_model(model)
+
+    if instrument is None:
+        instrument = agent.Agent._instrument_default  # pyright: ignore[reportPrivateUsage]
+
+    return instrumented_models.instrument_model(model_instance, instrument)
diff --git a/pydantic_ai_slim/pydantic_ai/messages.py b/pydantic_ai_slim/pydantic_ai/messages.py
index 83c3bfc236..027aece200 100644
--- a/pydantic_ai_slim/pydantic_ai/messages.py
+++ b/pydantic_ai_slim/pydantic_ai/messages.py
@@ -452,6 +452,11 @@ class ModelRequest:
     kind: Literal['request'] = 'request'
     """Message type identifier, this is available on all parts as a discriminator."""
 
+    @classmethod
+    def user_text_prompt(cls, user_prompt: str, *, instructions: str | None = None) -> ModelRequest:
+        """Create a `ModelRequest` with a single user prompt as text."""
+        return cls(parts=[UserPromptPart(user_prompt)], instructions=instructions)
+
 
 @dataclass
 class TextPart:
diff --git a/pydantic_ai_slim/pydantic_ai/models/__init__.py b/pydantic_ai_slim/pydantic_ai/models/__init__.py
index eb82de2bcb..b0b666ed08 100644
--- a/pydantic_ai_slim/pydantic_ai/models/__init__.py
+++ b/pydantic_ai_slim/pydantic_ai/models/__init__.py
@@ -260,9 +260,9 @@
 class ModelRequestParameters:
     """Configuration for an agent's request to a model, specifically related to tools and output handling."""
 
-    function_tools: list[ToolDefinition]
-    allow_text_output: bool
-    output_tools: list[ToolDefinition]
+    function_tools: list[ToolDefinition] = field(default_factory=list)
+    allow_text_output: bool = True
+    output_tools: list[ToolDefinition] = field(default_factory=list)
 
 
 class Model(ABC):
diff --git a/pydantic_ai_slim/pydantic_ai/models/instrumented.py b/pydantic_ai_slim/pydantic_ai/models/instrumented.py
index 813193738e..04c21bbe2a 100644
--- a/pydantic_ai_slim/pydantic_ai/models/instrumented.py
+++ b/pydantic_ai_slim/pydantic_ai/models/instrumented.py
@@ -26,6 +26,8 @@
 from . import KnownModelName, Model, ModelRequestParameters, StreamedResponse
 from .wrapper import WrapperModel
 
+__all__ = 'instrument_model', 'InstrumentationSettings', 'InstrumentedModel'
+
 MODEL_SETTING_ATTRIBUTES: tuple[
     Literal[
         'max_tokens',
@@ -48,6 +50,17 @@
 ANY_ADAPTER = TypeAdapter[Any](Any)
 
 
+def instrument_model(model: Model, instrument: InstrumentationSettings | bool) -> Model:
+    """Instrument a model with OpenTelemetry/logfire."""
+    if instrument and not isinstance(model, InstrumentedModel):
+        if instrument is True:
+            instrument = InstrumentationSettings()
+
+        model = InstrumentedModel(model, instrument)
+
+    return model
+
+
 @dataclass(init=False)
 class InstrumentationSettings:
     """Options for instrumenting models and agents with OpenTelemetry.
diff --git a/pydantic_ai_slim/pydantic_ai/models/test.py b/pydantic_ai_slim/pydantic_ai/models/test.py
index 2e40072c47..a98c637896 100644
--- a/pydantic_ai_slim/pydantic_ai/models/test.py
+++ b/pydantic_ai_slim/pydantic_ai/models/test.py
@@ -150,7 +150,7 @@ def _get_output(self, model_request_parameters: ModelRequestParameters) -> _Wrap
         elif model_request_parameters.output_tools:
             return _WrappedToolOutput(None)
         else:
-            return _WrappedTextOutput(None)  # pragma: no cover
+            return _WrappedTextOutput(None)
 
     def _request(
         self,
diff --git a/tests/test_direct.py b/tests/test_direct.py
new file mode 100644
index 0000000000..46f409ef01
--- /dev/null
+++ b/tests/test_direct.py
@@ -0,0 +1,117 @@
+from contextlib import contextmanager
+from datetime import timezone
+
+import pytest
+from inline_snapshot import snapshot
+
+from pydantic_ai import Agent
+from pydantic_ai.direct import (
+    _prepare_model,  # pyright: ignore[reportPrivateUsage]
+    model_request,
+    model_request_stream,
+    model_request_sync,
+)
+from pydantic_ai.messages import (
+    ModelRequest,
+    ModelResponse,
+    PartDeltaEvent,
+    PartStartEvent,
+    TextPart,
+    TextPartDelta,
+    ToolCallPart,
+)
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.models.instrumented import InstrumentedModel
+from pydantic_ai.models.test import TestModel
+from pydantic_ai.tools import ToolDefinition
+from pydantic_ai.usage import Usage
+
+from .conftest import IsNow, IsStr
+
+pytestmark = pytest.mark.anyio
+
+
+async def test_model_request():
+    model_response = await model_request('test', [ModelRequest.user_text_prompt('x')])
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[TextPart(content='success (no tool calls)')],
+            model_name='test',
+            timestamp=IsNow(tz=timezone.utc),
+            usage=Usage(requests=1, request_tokens=51, response_tokens=4, total_tokens=55),
+        )
+    )
+
+
+async def test_model_request_tool_call():
+    model_response = await model_request(
+        'test',
+        [ModelRequest.user_text_prompt('x')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(name='tool_name', description='', parameters_json_schema={'type': 'object'})
+            ],
+            allow_text_output=False,
+        ),
+    )
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[ToolCallPart(tool_name='tool_name', args={}, tool_call_id=IsStr(regex='pyd_ai_.*'))],
+            model_name='test',
+            timestamp=IsNow(tz=timezone.utc),
+            usage=Usage(requests=1, request_tokens=51, response_tokens=2, total_tokens=53),
+        )
+    )
+
+
+def test_model_request_sync():
+    model_response = model_request_sync('test', [ModelRequest.user_text_prompt('x')])
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[TextPart(content='success (no tool calls)')],
+            model_name='test',
+            timestamp=IsNow(tz=timezone.utc),
+            usage=Usage(requests=1, request_tokens=51, response_tokens=4, total_tokens=55),
+        )
+    )
+
+
+async def test_model_request_stream():
+    async with model_request_stream('test', [ModelRequest.user_text_prompt('x')]) as stream:
+        chunks = [chunk async for chunk in stream]
+    assert chunks == snapshot(
+        [
+            PartStartEvent(index=0, part=TextPart(content='')),
+            PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='success ')),
+            PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='(no ')),
+            PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='tool ')),
+            PartDeltaEvent(index=0, delta=TextPartDelta(content_delta='calls)')),
+        ]
+    )
+
+
+@contextmanager
+def set_instrument_default(value: bool):
+    """Context manager to temporarily set the default instrumentation value."""
+    initial_value = Agent._instrument_default  # pyright: ignore[reportPrivateUsage]
+    try:
+        Agent._instrument_default = value  # pyright: ignore[reportPrivateUsage]
+        yield
+    finally:
+        Agent._instrument_default = initial_value  # pyright: ignore[reportPrivateUsage]
+
+
+def test_prepare_model():
+    with set_instrument_default(False):
+        model = _prepare_model('test', None)
+        assert isinstance(model, TestModel)
+
+        model = _prepare_model('test', True)
+        assert isinstance(model, InstrumentedModel)
+
+    with set_instrument_default(True):
+        model = _prepare_model('test', None)
+        assert isinstance(model, InstrumentedModel)
+
+        model = _prepare_model('test', False)
+        assert isinstance(model, TestModel)
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 2f650aa538..2331ff0974 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -394,6 +394,11 @@ async def list_tools() -> list[None]:
             'steps': ['Mix the ingredients', 'Bake at 350°F for 30 minutes'],
         },
     ),
+    'What is 123 / 456?': ToolCallPart(
+        tool_name='divide',
+        args={'numerator': '123', 'denominator': '456'},
+        tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+    ),
 }
 
 tool_responses: dict[tuple[str, str], str] = {


Reviews:
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:34:17Z
user: login: Kludex
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T13:37:38Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:48:09Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T13:49:26Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:27:24Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:28:09Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:28:18Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:29:02Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:29:47Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T20:35:46Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-01T20:38:29Z
user: login: alexmojaki
body: I think the high level API should be using the low level API code path to make sure things stay in sync.

In the agent graph I see this:

```python
        model_request_parameters = ctx.deps.model.customize_request_parameters(model_request_parameters)
        model_response, request_usage = await ctx.deps.model.request(
            ctx.state.message_history, model_settings, model_request_parameters
        )
```

That `customize_request_parameters` seems important and missing from the low level code.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T20:49:56Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T08:51:34Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T09:05:08Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T09:29:41Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T09:29:58Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T12:34:50Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T13:21:56Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T14:11:25Z
user: login: alexmojaki
body: As discussed, I think we should refer to this as the 'direct' API

(or biodynamic)
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T11:19:42Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T11:34:13Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:01:17Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:20:56Z
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This PR introduces a new low-level "direct" API for making imperative requests to language models with minimal abstraction. Key changes include:
- Adding the "direct" module with three new methods: model_request, model_request_sync, and model_request_stream.
- Adjusting default values for ModelRequestParameters and enhancing documentation and tests to support the new API.

### Reviewed Changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

<details>
<summary>Show a summary per file</summary>

| File                                                    | Description                                             |
| ------------------------------------------------------- | ------------------------------------------------------- |
| tests/test_examples.py                                  | Added a test case demonstrating tool call using ToolCallPart. |
| tests/test_direct.py                                    | New tests ensuring functionality of the direct API.   |
| pydantic_ai_slim/pydantic_ai/models/test.py             | Updated return of _WrappedTextOutput with pragma removed. |
| pydantic_ai_slim/pydantic_ai/models/instrumented.py       | Introduced instrument_model utility and updated __all__.|
| pydantic_ai_slim/pydantic_ai/models/__init__.py          | Set default field values for ModelRequestParameters.    |
| pydantic_ai_slim/pydantic_ai/messages.py                 | Added a new class method user_text_prompt to ModelRequest.|
| pydantic_ai_slim/pydantic_ai/direct.py                   | New module providing direct request methods.          |
| pydantic_ai_slim/pydantic_ai/agent.py                    | Updated model instrumentation to use instrument_model.  |
| mkdocs.yml                                              | Updated navigation to include the direct API docs.      |
| docs/direct.md                                          | New documentation for the direct API with examples.     |
| docs/api/direct.md                                      | API docs page for the direct API module.                |
</details>





state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:25:20Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:33:57Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:34:12Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:36:15Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:36:39Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:37:48Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:40:15Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:41:46Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:45:55Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T12:50:11Z
user: login: samuelcolvin
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T12:53:45Z
user: login: alexmojaki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T13:00:44Z
user: login: alexmojaki
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T13:06:26Z

Review comments:
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: alexmojaki
body: Other pydantic-ai docs like https://ai.pydantic.dev/logfire/ use `Agent.instrument_all()`, not `logfire.instrument_pydantic_ai()`.
created_at: 2025-05-01T13:34:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: I think I know what you mean here but this statement seems confusing.
created_at: 2025-05-01T13:35:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: Can we make usage an attribute on response? Why does this return a tuple and why commit to that?
created_at: 2025-05-01T13:36:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: Kludex
body: https://github.com/pydantic/pydantic-ai/pull/1625/
created_at: 2025-05-01T13:37:37Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+        print(request_usage)
+        '''
+        Usage(
+            requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: `requests=0`?
created_at: 2025-05-01T13:37:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: i know this is 'low level' but i think it's quite valuable for the non-agent API to be user friendly. these feel like gory details, especially the tool definitions inside `model_request_parameters`. i worry that this might make it hard to change things in the future.
created_at: 2025-05-01T13:42:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+        print(request_usage)
+        '''
+        Usage(
+            requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: Same point as https://github.com/pydantic/pydantic-ai/pull/1599/files#r2070275593
created_at: 2025-05-01T13:46:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: alexmojaki
body: does that not make it look like you have to use logfire and can't use plain otel?
created_at: 2025-05-01T13:49:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+        print(request_usage)
+        '''
+        Usage(
+            requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: maybe we can fix that in tests.
created_at: 2025-05-01T20:27:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: samuelcolvin
body: that's what others are recommending already, I'd be open to having a "usage without logfire section" which shows `Agent.instrument_all()`.
created_at: 2025-05-01T20:28:09Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: I'll remove.
created_at: 2025-05-01T20:28:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: I think it's fine. It's no worse than the model SDKs I think.
created_at: 2025-05-01T20:29:02Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
path: docs/low_level.md
user: login: samuelcolvin
body: Needs "... can be used as is" comment.
created_at: 2025-05-01T20:29:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: alexmojaki
body: what do you mean by others?
created_at: 2025-05-01T20:35:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,136 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response, usage_info = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(usage_info)
+"""
+Usage(requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response, cost = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+    print(cost)
+    """
+    Usage(
+        requests=0, request_tokens=55, response_tokens=7, total_tokens=62, details=None
+    )
+    """
+```
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
path: docs/low_level.md
user: login: samuelcolvin
body: sorry, long day, that's what I'm recommending everywhere else and seeing everywhere since it's consistent with how logfire can instrument other libraries.
created_at: 2025-05-01T20:38:28Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: I don't agree, usage in agents can span multiple requests so it doesn't make sense to put it into `ModelResponse`, and I don't want to create new types just for this interface.
created_at: 2025-05-02T08:51:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: so why is it different for streaming?
created_at: 2025-05-02T09:05:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+        print(request_usage)
+        '''
+        Usage(
+            requests=0, request_tokens=56, response_tokens=1, total_tokens=57, details=None
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: fixed.
created_at: 2025-05-02T09:29:41Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,220 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> tuple[messages.ModelResponse, usage.Usage]:
+    """Make a non-streamed request to a model.
+
+    This method is roughly equivalent to [`Agent.run`][pydantic_ai.Agent.run].
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.low_level import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response, request_usage = await model_request(
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: okay, I've looked at this again, and I think I've fixed it.
created_at: 2025-05-02T09:29:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
path: docs/low_level.md
user: login: alexmojaki
body: I think we should aim to make this more ergonomic in the not too distant future, e.g

```python
model_response = await model_request(
    'openai:gpt-4.1-nano',
    'What is 123 / 456?',
    function_tools=[Divide],
)
```

I don't think the current API prevents having this in the future, but it's worth keeping in mind.
created_at: 2025-05-02T12:14:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
path: docs/low_level.md
user: login: alexmojaki
body: Does this help with the example?
created_at: 2025-05-02T12:15:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
path: docs/low_level.md
user: login: alexmojaki
body: Personally I find this sentence contradictory
created_at: 2025-05-02T12:17:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
path: docs/low_level.md
user: login: alexmojaki
body: Should this heading mention logfire?
created_at: 2025-05-02T12:24:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
path: docs/low_level.md
user: login: alexmojaki
body: Why this link to the agents page? Would the agents guide make more sense than the API page?
created_at: 2025-05-02T12:28:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
path: docs/low_level.md
user: login: alexmojaki
body: Are we no longer capitalising logfire?
created_at: 2025-05-02T12:33:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+_(This example is complete, it can be run "as is")_
+
+See [Debugging and Monitoring](logfire.md) for more details.
path: docs/low_level.md
user: login: alexmojaki
body: ```suggestion
See [Debugging and Monitoring](logfire.md) for more details, including how to instrument with plain OpenTelemetry without logfire.
```
created_at: 2025-05-02T12:34:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+_(This example is complete, it can be run "as is")_
path: docs/low_level.md
user: login: alexmojaki
body: How about a screenshot?
created_at: 2025-05-02T12:34:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,234 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+import dataclasses
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'LowLevelModelResponse', 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+@dataclasses.dataclass
+class LowLevelModelResponse(messages.ModelResponse):
+    """Subclass of [`ModelResponse`][pydantic_ai.messages.ModelResponse] that includes usage information."""
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: I'm still not sure why the usage isn't included in ModelResponse. Is it because people might misinterpret it to mean the usage of the whole agent run? What if it was called `request_usage`? People might actually want to access that info via `agent_run.all_messages()`
created_at: 2025-05-02T13:05:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,234 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+import dataclasses
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'LowLevelModelResponse', 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+@dataclasses.dataclass
+class LowLevelModelResponse(messages.ModelResponse):
+    """Subclass of [`ModelResponse`][pydantic_ai.messages.ModelResponse] that includes usage information."""
+
+    # the default_factor is required here to passify dataclasses since `ModelResponse` has fields with defaults :-(
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: ```suggestion
    # the default_factor is required here to pacify dataclasses since `ModelResponse` has fields with defaults :-(
```
created_at: 2025-05-02T13:06:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,234 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+import dataclasses
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'LowLevelModelResponse', 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+@dataclasses.dataclass
+class LowLevelModelResponse(messages.ModelResponse):
+    """Subclass of [`ModelResponse`][pydantic_ai.messages.ModelResponse] that includes usage information."""
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: Otherwise does this name need to be tied to the module? What about `ModelResponseWithUsage`?
created_at: 2025-05-02T13:19:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,234 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+import dataclasses
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'LowLevelModelResponse', 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+@dataclasses.dataclass
+class LowLevelModelResponse(messages.ModelResponse):
+    """Subclass of [`ModelResponse`][pydantic_ai.messages.ModelResponse] that includes usage information."""
+
+    # the default_factor is required here to passify dataclasses since `ModelResponse` has fields with defaults :-(
+    usage: usage.Usage = dataclasses.field(default_factory=usage.Usage)
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: alexmojaki
body: ```suggestion
    usage: usage.Usage = dataclasses.field(kw_only=True)
```

Would this work?
created_at: 2025-05-02T13:21:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,234 @@
+"""Low-level methods to make requests directly to models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can request all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+import dataclasses
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings, usage
+from .models import instrumented as instrumented_models
+
+__all__ = 'LowLevelModelResponse', 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+@dataclasses.dataclass
+class LowLevelModelResponse(messages.ModelResponse):
+    """Subclass of [`ModelResponse`][pydantic_ai.messages.ModelResponse] that includes usage information."""
path: pydantic_ai_slim/pydantic_ai/low_level.py
user: login: samuelcolvin
body: ye, we can probably change it everywhere. Now I see it's the usage for just that request, I think it makes more sense to just add it to `ModelResponse`
created_at: 2025-05-02T14:11:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retring, structured output parsing, and more.
path: docs/direct.md
user: login: alexmojaki
body: ```suggestion
For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
```
created_at: 2025-05-13T11:34:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
+
+```python {title="low_level_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+_(This example is complete, it can be run "as is")_
path: docs/low_level.md
user: login: samuelcolvin
body: I think fine for now.
created_at: 2025-05-13T12:01:17Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+_(This example is complete, it can be run "as is")_
+
+You can also enable OpenTelemetry on a per call basis:
+
+```python {title="direct_instrumented.py" hl_lines="1 6 12"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')],
+    instrument=True
+)
+
+print(model_response.parts[0].content)
+#> Paris
+```
+
+See [Debugging and Monitoring](logfire.md) for more details, including how to instrument with plain OpenTelemetry without logfire.
path: docs/direct.md
user: login: alexmojaki
body: ```suggestion
See [Debugging and Monitoring](logfire.md) for more details, including how to instrument with plain OpenTelemetry without Logfire.
```
created_at: 2025-05-13T12:17:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
```
created_at: 2025-05-13T12:17:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return await model_instance.request(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def model_request_sync(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a Synchronous, non-streamed request to a model.
+
+    This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
+    `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
+
+    ```py title="model_request_sync_example.py"
+    from pydantic_ai.direct import model_request_sync
+    from pydantic_ai.messages import ModelRequest
+
+    model_response = model_request_sync(
+        'anthropic:claude-3-5-haiku-latest',
+        [ModelRequest.user_text_prompt('What is the capital of France?')]
+    )
+    print(model_response)
+    '''
+    ModelResponse(
+        parts=[TextPart(content='Paris', part_kind='text')],
+        usage=Usage(
+            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
+        ),
+        model_name='claude-3-5-haiku-latest',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    '''
+    ```
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
```
created_at: 2025-05-13T12:17:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return await model_instance.request(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def model_request_sync(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a Synchronous, non-streamed request to a model.
+
+    This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
+    `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
+
+    ```py title="model_request_sync_example.py"
+    from pydantic_ai.direct import model_request_sync
+    from pydantic_ai.messages import ModelRequest
+
+    model_response = model_request_sync(
+        'anthropic:claude-3-5-haiku-latest',
+        [ModelRequest.user_text_prompt('What is the capital of France?')]
+    )
+    print(model_response)
+    '''
+    ModelResponse(
+        parts=[TextPart(content='Paris', part_kind='text')],
+        usage=Usage(
+            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
+        ),
+        model_name='claude-3-5-haiku-latest',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    '''
+    ```
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    return _get_event_loop().run_until_complete(
+        model_request(
+            model,
+            messages,
+            model_settings=model_settings,
+            model_request_parameters=model_request_parameters,
+            instrument=instrument,
+        )
+    )
+
+
+@asynccontextmanager
+async def model_request_stream(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> AsyncIterator[models.StreamedResponse]:
+    """Make a streamed async request to a model.
+
+    ```py {title="model_request_stream_example.py"}
+
+    from pydantic_ai.direct import model_request_stream
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        messages = [ModelRequest.user_text_prompt('Who was Albert Einstein?')]
+        async with model_request_stream( 'openai:gpt-4.1-mini', messages) as stream:
+            chunks = []
+            async for chunk in stream:
+                chunks.append(chunk)
+            print(chunks)
+            '''
+            [
+                PartStartEvent(
+                    index=0,
+                    part=TextPart(content='Albert Einstein was ', part_kind='text'),
+                    event_kind='part_start',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(
+                        content_delta='a German-born theoretical ', part_delta_kind='text'
+                    ),
+                    event_kind='part_delta',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(content_delta='physicist.', part_delta_kind='text'),
+                    event_kind='part_delta',
+                ),
+            ]
+            '''
+    ```
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/logfire, if `None` the value from
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
```
created_at: 2025-05-13T12:17:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+from contextlib import contextmanager
+from datetime import timezone
+
+import pytest
+from inline_snapshot import snapshot
+
+from pydantic_ai import Agent
+from pydantic_ai.direct import (
+    _prepare_model,  # pyright: ignore[reportPrivateUsage]
+    model_request,
+    model_request_stream,
+    model_request_sync,
+)
+from pydantic_ai.messages import (
+    ModelRequest,
+    ModelResponse,
+    PartDeltaEvent,
+    PartStartEvent,
+    TextPart,
+    TextPartDelta,
+    ToolCallPart,
+)
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.models.instrumented import InstrumentedModel
+from pydantic_ai.models.test import TestModel
+from pydantic_ai.tools import ToolDefinition
+from pydantic_ai.usage import Usage
+
+from .conftest import IsNow, IsStr
+
+pytestmark = pytest.mark.anyio
+
+
+async def test_model_request():
+    model_response = await model_request('test', [ModelRequest.user_text_prompt('x')])
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[TextPart(content='success (no tool calls)')],
+            model_name='test',
+            timestamp=IsNow(tz=timezone.utc),
+            usage=Usage(requests=1, request_tokens=51, response_tokens=4, total_tokens=55),
+        )
+    )
+
+
+async def test_model_request_tool_call():
+    model_response = await model_request(
+        'test',
+        [ModelRequest.user_text_prompt('x')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[ToolDefinition(name='tool_name', description='', parameters_json_schema={})],
+            allow_text_output=False,
+        ),
+    )
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[ToolCallPart(tool_name='tool_name', args='a', tool_call_id=IsStr(regex='pyd_ai_.*'))],
path: tests/test_direct.py
user: login: alexmojaki
body: Why is there an argument here?
created_at: 2025-05-13T12:20:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
path: docs/direct.md
user: login: Copilot
body: [nitpick] The example snippet contains a triple-quoted string immediately after printing model_response.usage. Consider replacing the string with an inline comment (using '#' or similar) to clarify that it is an expected output rather than executable code.
```suggestion
# Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
```
created_at: 2025-05-13T12:25:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
path: docs/direct.md
user: login: alexmojaki
body: ```suggestion
The `direct` module provides low-level methods for making imperative requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
```
created_at: 2025-05-13T12:25:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
path: docs/direct.md
user: login: alexmojaki
body: ```suggestion
- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a non-streamed synchronous request to a model
```
created_at: 2025-05-13T12:26:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent] you can enable OpenTelemetry/logfire instrumentation with just a few extra lines
path: docs/low_level.md
user: login: alexmojaki
body: Unresolved for the first comment about the agents guide
created_at: 2025-05-13T12:31:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
path: docs/direct.md
user: login: alexmojaki
body: ```suggestion
    [ModelRequest.user_text_prompt('What is the capital of France?')],
```

(was double checking the difference between this snippet and the next)

shouldn't this have been flagged by ruff?
created_at: 2025-05-13T12:33:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,115 @@
+from contextlib import contextmanager
+from datetime import timezone
+
+import pytest
+from inline_snapshot import snapshot
+
+from pydantic_ai import Agent
+from pydantic_ai.direct import (
+    _prepare_model,  # pyright: ignore[reportPrivateUsage]
+    model_request,
+    model_request_stream,
+    model_request_sync,
+)
+from pydantic_ai.messages import (
+    ModelRequest,
+    ModelResponse,
+    PartDeltaEvent,
+    PartStartEvent,
+    TextPart,
+    TextPartDelta,
+    ToolCallPart,
+)
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.models.instrumented import InstrumentedModel
+from pydantic_ai.models.test import TestModel
+from pydantic_ai.tools import ToolDefinition
+from pydantic_ai.usage import Usage
+
+from .conftest import IsNow, IsStr
+
+pytestmark = pytest.mark.anyio
+
+
+async def test_model_request():
+    model_response = await model_request('test', [ModelRequest.user_text_prompt('x')])
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[TextPart(content='success (no tool calls)')],
+            model_name='test',
+            timestamp=IsNow(tz=timezone.utc),
+            usage=Usage(requests=1, request_tokens=51, response_tokens=4, total_tokens=55),
+        )
+    )
+
+
+async def test_model_request_tool_call():
+    model_response = await model_request(
+        'test',
+        [ModelRequest.user_text_prompt('x')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[ToolDefinition(name='tool_name', description='', parameters_json_schema={})],
+            allow_text_output=False,
+        ),
+    )
+    assert model_response == snapshot(
+        ModelResponse(
+            parts=[ToolCallPart(tool_name='tool_name', args='a', tool_call_id=IsStr(regex='pyd_ai_.*'))],
path: tests/test_direct.py
user: login: samuelcolvin
body: good catch, it was because the tool parameters schema was wrong.
created_at: 2025-05-13T12:33:57Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
path: docs/low_level.md
user: login: samuelcolvin
body: yes I think it does.
created_at: 2025-05-13T12:36:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
path: docs/low_level.md
user: login: samuelcolvin
body: okay, let's change it everywhere, it's copied from other places.
created_at: 2025-05-13T12:36:39Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,139 @@
+# Low-Level Model Requests
+
+The low-level module provides direct access to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to request all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.low_level.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.low_level.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.low_level.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the low-level API to make a basic request:
+
+```python title="low_level_basic.py"
+from pydantic_ai.low_level import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the low-level API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.low_level import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    LowLevelModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use Low-Level API vs Agent
+
+The low-level API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
path: docs/low_level.md
user: login: samuelcolvin
body: done.
created_at: 2025-05-13T12:37:48Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
```
created_at: 2025-05-13T12:38:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: should this also be on the other two methods?
created_at: 2025-05-13T12:39:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
path: docs/direct.md
user: login: samuelcolvin
body: I think ruff doesn't mind adding the comma means "don't compact this on to one line"
created_at: 2025-05-13T12:40:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return await model_instance.request(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def model_request_sync(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a Synchronous, non-streamed request to a model.
+
+    This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
+    `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
+
+    ```py title="model_request_sync_example.py"
+    from pydantic_ai.direct import model_request_sync
+    from pydantic_ai.messages import ModelRequest
+
+    model_response = model_request_sync(
+        'anthropic:claude-3-5-haiku-latest',
+        [ModelRequest.user_text_prompt('What is the capital of France?')]
+    )
+    print(model_response)
+    '''
+    ModelResponse(
+        parts=[TextPart(content='Paris', part_kind='text')],
+        usage=Usage(
+            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None,
```

Just so that it expands and makes it easier to diff the methods
created_at: 2025-05-13T12:40:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+"""Methods for making imperative requests to language models with minimal abstraction.
+
+These methods allow you to make requests to LLMs where the only abstraction is input and output schema
+translation so you can use all models with the same API.
+
+These methods are thin wrappers around [`Model`][pydantic_ai.models.Model] implementations.
+"""
+
+from __future__ import annotations as _annotations
+
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+
+from pydantic_graph._utils import get_event_loop as _get_event_loop
+
+from . import agent, messages, models, settings
+from .models import instrumented as instrumented_models
+
+__all__ = 'model_request', 'model_request_sync', 'model_request_stream'
+
+
+async def model_request(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a non-streamed request to a model.
+
+    ```py title="model_request_example.py"
+    from pydantic_ai.direct import model_request
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        model_response = await model_request(
+            'anthropic:claude-3-5-haiku-latest',
+            [ModelRequest.user_text_prompt('What is the capital of France?')]  # (1)!
+        )
+        print(model_response)
+        '''
+        ModelResponse(
+            parts=[TextPart(content='Paris', part_kind='text')],
+            usage=Usage(
+                requests=1,
+                request_tokens=56,
+                response_tokens=1,
+                total_tokens=57,
+                details=None,
+            ),
+            model_name='claude-3-5-haiku-latest',
+            timestamp=datetime.datetime(...),
+            kind='response',
+        )
+        '''
+    ```
+
+    1. See [`ModelRequest.user_text_prompt`][pydantic_ai.messages.ModelRequest.user_text_prompt] for details.
+
+    Then
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    model_instance = _prepare_model(model, instrument)
+    return await model_instance.request(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+
+
+def model_request_sync(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> messages.ModelResponse:
+    """Make a Synchronous, non-streamed request to a model.
+
+    This is a convenience method that wraps [`model_request`][pydantic_ai.direct.model_request] with
+    `loop.run_until_complete(...)`. You therefore can't use this method inside async code or if there's an active event loop.
+
+    ```py title="model_request_sync_example.py"
+    from pydantic_ai.direct import model_request_sync
+    from pydantic_ai.messages import ModelRequest
+
+    model_response = model_request_sync(
+        'anthropic:claude-3-5-haiku-latest',
+        [ModelRequest.user_text_prompt('What is the capital of France?')]
+    )
+    print(model_response)
+    '''
+    ModelResponse(
+        parts=[TextPart(content='Paris', part_kind='text')],
+        usage=Usage(
+            requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None
+        ),
+        model_name='claude-3-5-haiku-latest',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    '''
+    ```
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        The model response and token usage associated with the request.
+    """
+    return _get_event_loop().run_until_complete(
+        model_request(
+            model,
+            messages,
+            model_settings=model_settings,
+            model_request_parameters=model_request_parameters,
+            instrument=instrument,
+        )
+    )
+
+
+@asynccontextmanager
+async def model_request_stream(
+    model: models.Model | models.KnownModelName | str,
+    messages: list[messages.ModelMessage],
+    *,
+    model_settings: settings.ModelSettings | None = None,
+    model_request_parameters: models.ModelRequestParameters | None = None,
+    instrument: instrumented_models.InstrumentationSettings | bool | None = None,
+) -> AsyncIterator[models.StreamedResponse]:
+    """Make a streamed async request to a model.
+
+    ```py {title="model_request_stream_example.py"}
+
+    from pydantic_ai.direct import model_request_stream
+    from pydantic_ai.messages import ModelRequest
+
+
+    async def main():
+        messages = [ModelRequest.user_text_prompt('Who was Albert Einstein?')]
+        async with model_request_stream( 'openai:gpt-4.1-mini', messages) as stream:
+            chunks = []
+            async for chunk in stream:
+                chunks.append(chunk)
+            print(chunks)
+            '''
+            [
+                PartStartEvent(
+                    index=0,
+                    part=TextPart(content='Albert Einstein was ', part_kind='text'),
+                    event_kind='part_start',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(
+                        content_delta='a German-born theoretical ', part_delta_kind='text'
+                    ),
+                    event_kind='part_delta',
+                ),
+                PartDeltaEvent(
+                    index=0,
+                    delta=TextPartDelta(content_delta='physicist.', part_delta_kind='text'),
+                    event_kind='part_delta',
+                ),
+            ]
+            '''
+    ```
+
+    Args:
+        model: The model to make a request to. We allow `str` here since the actual list of allowed models changes frequently.
+        messages: Messages to send to the model
+        model_settings: optional model settings
+        model_request_parameters: optional model request parameters
+        instrument: Whether to instrument the request with OpenTelemetry/Logfire, if `None` the value from
+            [`logfire.instrument_pydantic_ai`][logfire.Logfire.instrument_pydantic_ai] is used.
+
+    Returns:
+        A [stream response][pydantic_ai.models.StreamedResponse] async context manager.
+    """
+    model_instance = _prepare_model(model, instrument)
+    stream_cxt_mgr = model_instance.request_stream(
+        messages,
+        model_settings,
+        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
+    )
+    async with stream_cxt_mgr as streamed_response:
+        yield streamed_response
path: pydantic_ai_slim/pydantic_ai/direct.py
user: login: alexmojaki
body: ```suggestion
    return model_instance.request_stream(
        messages,
        model_settings,
        model_instance.customize_request_parameters(model_request_parameters or models.ModelRequestParameters()),
    )
```
created_at: 2025-05-13T12:45:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
path: docs/direct.md
user: login: alexmojaki
body: <img width="1232" alt="Screenshot 2025-05-13 at 14 49 48" src="https://github.com/user-attachments/assets/f85298c7-3b49-4126-bdf6-720e5b16e773" />

 so why didn't it compact the line?
created_at: 2025-05-13T12:50:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
path: docs/direct.md
user: login: samuelcolvin
body: because the lin length limit in docs is 88
created_at: 2025-05-13T12:53:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,160 @@
+# Direct Model Requests
+
+The `direct` module provides low-level methods for making imperative requests to language models with minimal abstraction. These methods allow you to make requests to LLMs where the only abstraction is input and output schema translation, enabling you to use all models with the same API.
+
+These methods are thin wrappers around the [`Model`][pydantic_ai.models.Model] implementations, offering a simpler interface when you don't need the full functionality of an [`Agent`][pydantic_ai.Agent].
+
+The following functions are available:
+
+- [`model_request`][pydantic_ai.direct.model_request]: Make a non-streamed async request to a model
+- [`model_request_sync`][pydantic_ai.direct.model_request_sync]: Make a synchronous non-streamed request to a model
+- [`model_request_stream`][pydantic_ai.direct.model_request_stream]: Make a streamed async request to a model
+
+## Basic Example
+
+Here's a simple example demonstrating how to use the direct API to make a basic request:
+
+```python title="direct_basic.py"
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
+)
+
+print(model_response.parts[0].content)
+#> Paris
+print(model_response.usage)
+"""
+Usage(requests=1, request_tokens=56, response_tokens=1, total_tokens=57, details=None)
+"""
+```
+
+_(This example is complete, it can be run "as is")_
+
+## Advanced Example with Tool Calling
+
+You can also use the direct API to work with function/tool calling.
+
+Even here we can use Pydantic to generate the JSON schema for the tool:
+
+```python
+from pydantic import BaseModel
+from typing_extensions import Literal
+
+from pydantic_ai.direct import model_request
+from pydantic_ai.messages import ModelRequest
+from pydantic_ai.models import ModelRequestParameters
+from pydantic_ai.tools import ToolDefinition
+
+
+class Divide(BaseModel):
+    """Divide two numbers."""
+
+    numerator: float
+    denominator: float
+    on_inf: Literal['error', 'infinity'] = 'infinity'
+
+
+async def main():
+    # Make a request to the model with tool access
+    model_response = await model_request(
+        'openai:gpt-4.1-nano',
+        [ModelRequest.user_text_prompt('What is 123 / 456?')],
+        model_request_parameters=ModelRequestParameters(
+            function_tools=[
+                ToolDefinition(
+                    name=Divide.__name__.lower(),
+                    description=Divide.__doc__ or '',
+                    parameters_json_schema=Divide.model_json_schema(),
+                )
+            ],
+            allow_text_output=True,  # Allow model to either use tools or respond directly
+        ),
+    )
+    print(model_response)
+    """
+    ModelResponse(
+        parts=[
+            ToolCallPart(
+                tool_name='divide',
+                args={'numerator': '123', 'denominator': '456'},
+                tool_call_id='pyd_ai_2e0e396768a14fe482df90a29a78dc7b',
+                part_kind='tool-call',
+            )
+        ],
+        usage=Usage(
+            requests=1,
+            request_tokens=55,
+            response_tokens=7,
+            total_tokens=62,
+            details=None,
+        ),
+        model_name='gpt-4.1-nano',
+        timestamp=datetime.datetime(...),
+        kind='response',
+    )
+    """
+```
+
+_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
+
+## When to Use the direct API vs Agent
+
+The direct API is ideal when:
+
+1. You need more direct control over model interactions
+2. You want to implement custom behavior around model requests
+3. You're building your own abstractions on top of model interactions
+
+For most application use cases, the higher-level [`Agent`][pydantic_ai.Agent] API provides a more convenient interface with additional features such as built-in tool execution, retrying, structured output parsing, and more.
+
+## OpenTelemetry Instrumentation
+
+As with [agents][pydantic_ai.Agent], you can enable OpenTelemetry/Logfire instrumentation with just a few extra lines
+
+```python {title="direct_instrumented.py" hl_lines="1 6 7"}
+import logfire
+
+from pydantic_ai.direct import model_request_sync
+from pydantic_ai.messages import ModelRequest
+
+logfire.configure()
+logfire.instrument_pydantic_ai()
+
+# Make a synchronous request to the model
+model_response = model_request_sync(
+    'anthropic:claude-3-5-haiku-latest',
+    [ModelRequest.user_text_prompt('What is the capital of France?')]
path: docs/direct.md
user: login: alexmojaki
body: then why doesn't it add the trailing comma?

adding a trailing comma in another line also didn't force it to expand as i expected
created_at: 2025-05-13T13:00:44Z
author_association: CONTRIBUTOR

PR comments:
user: login: hyperlint-ai-deprecated[bot]
created_at: 2025-04-25T22:44:36Z
body: ## PR Change Summary

Added a new public API for low-level functionality in the pydantic_ai module, with initial documentation provided.
- Introduced the `low_level` public API in the pydantic_ai module
- Added initial documentation for the new API
- Enhanced the overall API structure




**Added Files**

- `docs/api/low_level.md`
 







---

<details>

<summary>How can I customize these reviews?</summary>

Check out the [Hyperlint AI Reviewer docs](https://docs.hyperlint.com/guides/ai-reviewer/) for more information on how to customize the review.

If you just want to ignore it on this PR, you can add the `hyperlint-ignore` label to the PR. Future changes won't trigger a Hyperlint review.

Note specifically for link checks, we only check the first 30 links in a file and we cache the results for several hours (for instance, if you just added a page, you might experience this). Our recommendation is to add `hyperlint-ignore` to the PR to ignore the link check for this PR.
</details>
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-25T22:48:36Z
body: ## Docs Preview

<table>
<tr>
<td><strong>commit:</strong></td>
<td><code>60ea552</code></td>
</tr>
<tr>
<td><strong>Preview URL:</strong></td>
<td><a href="https://a7fe1714-pydantic-ai-previews.pydantic.workers.dev">https://a7fe1714-pydantic-ai-previews.pydantic.workers.dev</a></td>
</tr>
</table>

author_association: CONTRIBUTOR
user: login: samuelcolvin
created_at: 2025-05-02T08:47:01Z
body: > I think the high level API should be using the low level API code path to make sure things stay in sync.
> 
> In the agent graph I see this:
> 
> ```python
>         model_request_parameters = ctx.deps.model.customize_request_parameters(model_request_parameters)
>         model_response, request_usage = await ctx.deps.model.request(
>             ctx.state.message_history, model_settings, model_request_parameters
>         )
> ```
> 
> That `customize_request_parameters` seems important and missing from the low level code.

I don't agree, the amount of code here is tiny, and there's some (minimal) savings by caching results, but I guess we can do that later if you like.
author_association: MEMBER
user: login: alexmojaki
created_at: 2025-05-02T08:49:30Z
body: `customize_request_parameters` isn't important?
author_association: CONTRIBUTOR
user: login: samuelcolvin
created_at: 2025-05-02T09:30:53Z
body: oh sorry, I'll look.
author_association: MEMBER