diff --git a/README.md b/README.md
index 719f1fb..96bc7d0 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,8 @@ your need fits:
- **[Tutorial: your first scoped model](docs/tutorial/first-scoped-model.md)** —
one hand-held lesson, one model to two projections.
- **[How-to guides](docs/how-to/README.md)** — short recipes: redact PII,
- trace data flow, PATCH models, FastAPI, ORM bridge, editor stubs, diagrams.
+ trace data flow, PATCH models, LLM tool schemas, FastAPI, ORM bridge, editor
+ stubs, diagrams.
- **[Reference](docs/reference/README.md)** — the [API](docs/reference/api.md),
the [`pydantic-prism` CLI](docs/reference/cli.md), and the [error table](docs/reference/errors.md).
- **[Explanation](docs/explanation/README.md)** — the scope algebra, why
diff --git a/ROADMAP.md b/ROADMAP.md
index 36532f3..a209769 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -24,15 +24,13 @@ documented. Links go to the how-to guide.
| FastAPI integration | Projections as `response_model=`; one object, many documented shapes | [Use with FastAPI](docs/how-to/use-with-fastapi.md) |
| ORM / SQLModel bridge | Make a SQLModel table or SQLAlchemy row the canonical; derive the DTO faces | [Bridge an ORM](docs/how-to/bridge-an-orm.md) |
| Per-projection schema metadata | Per-scope `description` / `examples` / `json_schema_extra` | [Vary a field's schema](docs/how-to/vary-schema-per-scope.md) |
+| LLM tool / function schemas | `Model.tool_schema(...)` / `Projection.tool_schema(...)`: OpenAI / Anthropic / Mistral tool envelopes (`envelope=False` for the bare schema), strict-mode normalization, `ToolSchemaDepthWarning` | [Derive an LLM tool schema](docs/how-to/derive-llm-tool-schema.md) |
+| Pydantic AI integration | Projections as agent `output_type` / typed tool args; bare schema for `ToolDefinition.parameters_json_schema` | [Use with Pydantic AI](docs/how-to/use-with-pydantic-ai.md) |
## Planned
Bets with real demand, listed with the honest boundary that gates each one.
-- **A first-class LLM tool-schema scope.** The mechanism — a field-filtering
- scope plus per-scope `description`/`examples` — already works. What is
- missing is a blessed convention and possibly a thin `tool_schema()` helper
- that normalizes the schema for OpenAI/Anthropic strict modes.
- **Entitlement / plan-tier gating docs.** Scope inheritance models a
Free < Pro < Enterprise ladder cleanly. Boundary: prism gates field
**presence** only — never numeric limits ("max 5 projects"), rate limits,
diff --git a/docs/README.md b/docs/README.md
index 2b0c4d2..a4f8b59 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,6 +22,8 @@ job. Pick the column that matches what you need right now.
- [Trace where classified data flows](how-to/trace-data-flow.md)
- [Prevent mass-assignment (read-only / write-only fields)](how-to/prevent-mass-assignment.md)
- [Build a PATCH / partial-update model](how-to/partial-update.md)
+- [Derive an LLM tool / function schema](how-to/derive-llm-tool-schema.md)
+- [Use projections with Pydantic AI](how-to/use-with-pydantic-ai.md)
- [Use projections with FastAPI](how-to/use-with-fastapi.md)
- [Bridge a SQLModel or SQLAlchemy ORM](how-to/bridge-an-orm.md)
- [Carry a custom pydantic base onto projections](how-to/carry-a-custom-base.md)
diff --git a/docs/badges/duration.svg b/docs/badges/duration.svg
index ed4a993..4ef8804 100644
--- a/docs/badges/duration.svg
+++ b/docs/badges/duration.svg
@@ -5,7 +5,7 @@
width="89.81"
height="20"
role="img"
- aria-label="duration: 2.3s"
+ aria-label="duration: 3.0s"
>
-
duration: 2.3s
+ duration: 3.0s
@@ -45,8 +45,8 @@
- 2.3s
- 2.3s
+ 3.0s
+ 3.0s
diff --git a/docs/badges/last-run.svg b/docs/badges/last-run.svg
index ca74f5a..2786b4d 100644
--- a/docs/badges/last-run.svg
+++ b/docs/badges/last-run.svg
@@ -5,7 +5,7 @@
width="191.3"
height="20"
role="img"
- aria-label="last run: 2026-06-11 14:12 UTC"
+ aria-label="last run: 2026-06-11 18:11 UTC"
>
- last run: 2026-06-11 14:12 UTC
+ last run: 2026-06-11 18:11 UTC
@@ -45,8 +45,8 @@
- 2026-06-11 14:12 UTC
- 2026-06-11 14:12 UTC
+ 2026-06-11 18:11 UTC
+ 2026-06-11 18:11 UTC
diff --git a/docs/badges/tests.svg b/docs/badges/tests.svg
index dceaab8..d353c03 100644
--- a/docs/badges/tests.svg
+++ b/docs/badges/tests.svg
@@ -5,7 +5,7 @@
width="67.64"
height="20"
role="img"
- aria-label="tests: 426"
+ aria-label="tests: 458"
>
- tests: 426
+ tests: 458
@@ -45,8 +45,8 @@
- 426
- 426
+ 458
+ 458
diff --git a/docs/badges/warnings.svg b/docs/badges/warnings.svg
index 6e9d9e0..b954ed7 100644
--- a/docs/badges/warnings.svg
+++ b/docs/badges/warnings.svg
@@ -5,7 +5,7 @@
width="76.81"
height="20"
role="img"
- aria-label="warnings: 3"
+ aria-label="warnings: 2"
>
- warnings: 3
+ warnings: 2
@@ -45,8 +45,8 @@
- 3
- 3
+ 2
+ 2
diff --git a/docs/how-to/README.md b/docs/how-to/README.md
index 91db178..5e06424 100644
--- a/docs/how-to/README.md
+++ b/docs/how-to/README.md
@@ -22,6 +22,11 @@ and shows the shortest correct way to get it. New to prism? Start with the
## Integration
+- [Derive an LLM tool / function schema](derive-llm-tool-schema.md) — emit an
+ OpenAI / Anthropic / Mistral tool schema that hides internal fields, with
+ `tool_schema()`.
+- [Use projections with Pydantic AI](use-with-pydantic-ai.md) — hand an agent a
+ narrowed view as `output_type` or a tool argument.
- [Use projections with FastAPI](use-with-fastapi.md) — one object, many
documented response shapes.
- [Bridge a SQLModel or SQLAlchemy ORM](bridge-an-orm.md) — make the table the
diff --git a/docs/how-to/derive-llm-tool-schema.md b/docs/how-to/derive-llm-tool-schema.md
new file mode 100644
index 0000000..76b0777
--- /dev/null
+++ b/docs/how-to/derive-llm-tool-schema.md
@@ -0,0 +1,148 @@
+# Derive an LLM tool / function schema
+
+**Goal:** hand an LLM a tool whose schema is exactly the fields the model should
+see — no internal columns, no audit metadata — without hand-writing a parallel
+schema dict. Tag the canonical model once, project to the scope the model may
+fill in, and let prism emit the provider envelope.
+
+The projection already does the hard part (it hides untagged and out-of-scope
+fields and carries per-scope descriptions); `tool_schema()` is the thin layer
+that normalizes that schema for a provider's strict mode and wraps it in the
+provider's tool/function envelope. No vendor SDK is imported — you get a plain
+`dict` to pass to `openai` / `anthropic` yourself.
+
+## Tag a tool-input scope, then ask for the schema
+
+```python
+from typing import Annotated, Optional
+
+from pydantic_prism import Scope, ScopedModel, scoped
+
+
+class Tool(Scope, description="Create a calendar event."): ...
+
+
+class Event(ScopedModel):
+ title: Annotated[str, scoped(Tool)]
+ attendees: Annotated[list[str], scoped(Tool)]
+ notes: Annotated[Optional[str], scoped(Tool)] = None
+ internal_id: int = 0 # untagged → never in any projection
+
+
+tool = Event.tool_schema(Tool, provider="openai")
+
+assert tool["type"] == "function"
+assert tool["function"]["name"] == "EventTool"
+assert tool["function"]["description"] == "Create a calendar event."
+assert tool["function"]["strict"] is True
+
+params = tool["function"]["parameters"]
+# Untagged fields never leak into the tool the model sees.
+assert "internal_id" not in params["properties"]
+```
+
+`Event.tool_schema(Tool, ...)` is the one-step convenience for
+`Event.scope(Tool).tool_schema(...)` — the two return the same dict, so use
+whichever reads better.
+
+## What `strict=True` changes
+
+OpenAI strict structured outputs require every property to be `required`, forbid
+`default`, and express optionality as a `"null"` union. `strict=True` (the
+default, and what OpenAI recommends) applies exactly those rewrites — the one
+place prism rewrites types rather than only filtering fields:
+
+```python
+# Every property is required, even the ones that were optional.
+assert set(params["required"]) == {"title", "attendees", "notes"}
+# `notes` (Optional, default None) became a nullable union with no default.
+assert {"type": "null"} in params["properties"]["notes"]["anyOf"]
+assert "default" not in params["properties"]["notes"]
+# Objects forbid unknown keys, at every level.
+assert params["additionalProperties"] is False
+```
+
+## Anthropic: plain JSON Schema, no rewriting
+
+Anthropic's tool `input_schema` is plain JSON Schema 2020-12 and honors
+`required` as written — so target it with `strict=False` and the schema stays
+faithful to your model's optionality:
+
+```python
+anthropic_tool = Event.tool_schema(Tool, provider="anthropic", strict=False)
+
+assert set(anthropic_tool) == {"name", "input_schema", "description"}
+# Only fields without a default are required — `notes` stays optional.
+assert anthropic_tool["input_schema"]["required"] == ["title", "attendees"]
+```
+
+## Mistral: the OpenAI-compatible format
+
+Mistral's tools use the same `{"type": "function", ...}` envelope as OpenAI
+(including the optional `strict` flag), so `provider="mistral"` produces the
+identical shape. Keep `strict=True` for its stricter validation, or pass
+`strict=False` to leave the schema faithful:
+
+```python
+mistral_tool = Event.tool_schema(Tool, provider="mistral")
+assert mistral_tool == Event.tool_schema(Tool, provider="openai")
+```
+
+The five-level depth limit below is an OpenAI structured-outputs constraint, so
+it is **not** applied to Mistral.
+
+## Mass-assignment-safe tool input
+
+When the model is *writing* (the tool fills in a record you will persist), build
+the write-side projection first so read-only (`Out`) fields are dropped — then
+ask it for the schema. `tool_schema()` works on any projection:
+
+```python
+from pydantic_prism import Out
+
+
+class Account(ScopedModel):
+ email: Annotated[str, scoped(Tool)]
+ created_at: Annotated[str, scoped(Tool, Out)] # server-set, read-only
+
+
+write_tool = Account.input(Tool).tool_schema(provider="openai")
+# The model can't set a read-only field it should never control.
+assert "created_at" not in write_tool["function"]["parameters"]["properties"]
+assert "email" in write_tool["function"]["parameters"]["properties"]
+```
+
+## Override the name and description
+
+`name` defaults to the projection class name and `description` falls back to a
+description on the `Scope` class (above) or the model's docstring. Override
+either explicitly — a good tool description is the single biggest lever on tool
+performance:
+
+```python
+named = Event.tool_schema(
+ Tool,
+ provider="openai",
+ name="create_event",
+ description="Schedule a new calendar event for the user.",
+)
+assert named["function"]["name"] == "create_event"
+```
+
+## Depth limit
+
+OpenAI strict structured outputs allow object nesting up to five levels. Under
+`provider="openai", strict=True`, a schema that nests deeper than that — or a
+recursive (self-referential) model, whose depth is unbounded — emits a
+[`ToolSchemaDepthWarning`](../reference/errors.md) at build time and returns the
+schema unchanged, so you learn about the likely rejection before the API does.
+Project to a shallower scope, or drop `strict=True`, to resolve it.
+
+## See also
+
+- [Use projections with Pydantic AI](use-with-pydantic-ai.md) — on a framework
+ that builds schemas from models, hand it the projection instead of an envelope.
+- [Prevent mass-assignment](prevent-mass-assignment.md) — the `input()` /
+ `output()` and `In` / `Out` mechanics the write-side example builds on.
+- [Vary a field's schema per projection](vary-schema-per-scope.md) — set the
+ per-scope `description` / `examples` that surface in the tool schema.
diff --git a/docs/how-to/use-with-pydantic-ai.md b/docs/how-to/use-with-pydantic-ai.md
new file mode 100644
index 0000000..9be89ef
--- /dev/null
+++ b/docs/how-to/use-with-pydantic-ai.md
@@ -0,0 +1,123 @@
+# Use projections with Pydantic AI
+
+**Goal:** give a [Pydantic AI](https://ai.pydantic.dev) agent a narrowed view of
+your model — so the LLM only ever sees the fields it should — without writing a
+second schema.
+
+Pydantic AI builds its tool and output schemas *itself*, from Pydantic model
+classes and function signatures, and applies each model's strict-mode transform
+through its own profiles. A prism projection **is** a real `BaseModel` subclass,
+so the idiomatic integration is to hand Pydantic AI the **projection** — you
+rarely need [`tool_schema()`](derive-llm-tool-schema.md) here at all. The
+examples below use `TestModel` so they run offline; in real use you would pass a
+model string like `"openai:gpt-4o"`.
+
+## Setup
+
+```python
+from typing import Annotated
+
+from pydantic_ai import Agent, RunContext, Tool
+from pydantic_ai.models.test import TestModel
+from pydantic_ai.tools import ToolDefinition
+
+from pydantic_prism import Scope, ScopedModel, scoped
+
+
+class LLMView(Scope, description="Create a customer order."): ...
+
+
+class Order(ScopedModel):
+ item: Annotated[str, scoped(LLMView)]
+ quantity: Annotated[int, scoped(LLMView)]
+ internal_cost: float = 0.0 # untagged → never in any projection
+
+
+OrderView = Order.scope(LLMView)
+```
+
+## A. Projection as `output_type` (the natural fit)
+
+Hand the projection straight to the agent. Pydantic AI generates the output-tool
+schema from it; prism's job is the narrowing — `internal_cost` never appears.
+
+```python
+# In real use: Agent("openai:gpt-4o", output_type=OrderView)
+agent = Agent(TestModel(), output_type=OrderView)
+result = agent.run_sync("Order three widgets")
+
+assert isinstance(result.output, OrderView)
+assert "internal_cost" not in OrderView.model_json_schema()["properties"]
+```
+
+## B. Projection as a typed tool argument
+
+A tool that takes the projection as its parameter type gets a schema built from
+the projection — `input()` first drops read-only (`Out`) fields, so the model
+can't over-post. Round-trip back to canonical with `from_projection`.
+
+```python
+order_agent = Agent(TestModel())
+
+
+@order_agent.tool_plain
+def create_order(order: OrderView) -> str:
+ saved = Order.from_projection(order)
+ return f"created order for {saved.item}"
+
+
+b = order_agent.run_sync("order widgets")
+assert "created order for" in str(b.output)
+```
+
+## C. A raw schema you control — `envelope=False`
+
+When you drop to a low-level `Tool` and set the parameters schema yourself (a
+`prepare` hook editing `ToolDefinition.parameters_json_schema`), prism gives you
+exactly that shape with `envelope=False` — the normalized parameters schema with
+no provider wrapper to dig through.
+
+```python
+def take_order(**fields: object) -> str:
+ return f"received {sorted(fields)}"
+
+
+async def use_prism_schema(
+ ctx: RunContext[None], tool_def: ToolDefinition
+) -> ToolDefinition:
+ tool_def.parameters_json_schema = Order.input(LLMView).tool_schema(
+ provider="openai", envelope=False
+ )
+ return tool_def
+
+
+raw_tool = Tool(
+ take_order,
+ name="take_order",
+ description="Create a customer order.",
+ takes_ctx=False,
+ prepare=use_prism_schema,
+)
+raw_agent = Agent(TestModel(), tools=[raw_tool])
+c = raw_agent.run_sync("order widgets")
+assert "received" in str(c.output)
+```
+
+## Which path
+
+- **A / B** are the idiomatic ones: hand Pydantic AI the projection as a model
+ and let it own schema generation. No `tool_schema()` needed.
+- **C** is for when you control the raw parameters schema directly —
+ `tool_schema(..., envelope=False)` returns just that dict.
+
+The full envelope form of `tool_schema()` (with the `{"type": "function", ...}`
+wrapper) is for calling the **raw** `openai` / `anthropic` / `mistral` SDKs — see
+[derive an LLM tool schema](derive-llm-tool-schema.md). On Pydantic AI you want
+the projection (A/B) or the bare schema (C).
+
+## See also
+
+- [Derive an LLM tool / function schema](derive-llm-tool-schema.md) — the
+ envelope form, for the raw provider SDKs.
+- [Prevent mass-assignment](prevent-mass-assignment.md) — the `input()` / `Out`
+ mechanics path B relies on.
diff --git a/docs/reference/api.md b/docs/reference/api.md
index 790fb23..d94415f 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -69,6 +69,7 @@ Prefer the operators for statically-known scopes.
| `Model.input(visible=None, *, name=None, bases=None, extra="forbid")` | classmethod | Write-side projection: `visible - Out` (drops read-only fields, deep). Defaults `name="{Model}In"` and `extra="forbid"`. `visible` is one scope/expression, falling back to `default_scope=` when omitted. |
| `Model.output(visible=None, *, name=None, bases=None)` | classmethod | Read-side projection: `visible - In` (drops write-only fields, deep). Defaults `name="{Model}Out"`; config untouched. `visible` is one scope/expression, falling back to `default_scope=`. |
| `Model.scopes()` | classmethod | `frozenset[type[Scope]]` of the atom scopes used in field tags. |
+| `Model.tool_schema(scope=None, *, provider="openai", strict=True, name=None, description=None, envelope=True)` | classmethod | One-step LLM tool / function schema for a scope — `Model.scope(scope).tool_schema(...)`. `scope` falls back to `default_scope=`. See [`Projection.tool_schema`](#projection--derived-classes) and [derive an LLM tool schema](../how-to/derive-llm-tool-schema.md). |
| `Model.from_projection(projection, /, **extra)` | classmethod | Complete projection → canonical instance; missing fields via `**extra` or canonical defaults. Rejects **partial** projections (use `with_updates`). |
| `Model.run_inherited_before(data)` | classmethod | Run inherited `@model_validator(mode="before")` hooks (nearest ancestor first), threading the result. Call inside a `@scoped_validator(mode="before")` whose logic depends on a base hook's transformation. Inherited hooks must be idempotent (they re-run under pydantic). Also on `Projection`. |
| `instance.with_updates(patch, /)` | method | Apply a (partial) projection's set fields as a PATCH; returns a new, re-validated instance. `self` unchanged. |
@@ -99,6 +100,7 @@ instance's `model_dump`.
|---|---|---|
| `Projection.from_canonical(instance, *, mode, by_alias, context, exclude_none, exclude_unset, exclude_defaults, narrow)` | classmethod | Canonical (or wider projection) instance → projected instance; kwargs forwarded to `model_dump`. The instance-level narrowing counterpart of re-projection. |
| `Projection.scope(scope, *, name=None, bases=None)` | classmethod | **Re-project**: derive a narrower projection from this one — `Source.scope(__prism_scope__ & scope)`. Only ever narrows (a view can't expose more than it has); returns a *sibling* projection of the canonical, not a subclass. `bases` defaults to this projection's `__prism_bases__`. |
+| `Projection.tool_schema(*, provider="openai", strict=True, name=None, description=None, envelope=True)` | classmethod | Render this projection as an LLM tool / function schema (a plain `dict` for the `openai` / `anthropic` / `mistral` SDK; `mistral` shares the OpenAI-compatible envelope). `strict=True` applies OpenAI strict-mode rewrites (all-required, nullable optionals, `additionalProperties: false`); `strict=False` leaves the schema faithful (Anthropic). `envelope=False` returns just the normalized parameters schema (no provider wrapper) — for a framework's own tool definition, e.g. [Pydantic AI](../how-to/use-with-pydantic-ai.md). `name` defaults to the class name; `description` falls back to the per-scope or canonical docstring. Emits `ToolSchemaDepthWarning` past OpenAI's 5-level limit (OpenAI only). See [the how-to](../how-to/derive-llm-tool-schema.md). |
| `Projection.__prism_source__` | ClassVar | The canonical `ScopedModel` class this projection derives from. |
| `Projection.__prism_scope__` | ClassVar | The `ScopeExpr` the projection was built for. |
| `Projection.__prism_bases__` | ClassVar | The carried bases tuple (`()` when none). |
diff --git a/docs/reference/errors.md b/docs/reference/errors.md
index 43db872..5c4a7df 100644
--- a/docs/reference/errors.md
+++ b/docs/reference/errors.md
@@ -56,7 +56,8 @@ concrete subclass for one kind.
```
PrismWarning(UserWarning)
├── PrismBaseDropWarning
-└── PrismOrderingWarning
+├── PrismOrderingWarning
+└── ToolSchemaDepthWarning
```
| warning | emitted when |
@@ -64,3 +65,4 @@ PrismWarning(UserWarning)
| `PrismWarning` | Base class for all prism advisory warnings; filter this to catch them all. |
| `PrismBaseDropWarning` | `Model.scope(...)` would drop a non-`ScopedModel` base's overridden `model_dump`/`model_validate` (or its model validators/serializers) because the base isn't carried. Declare `projection_bases=(Base,)` to carry it, or `projection_bases=()` to silence. One-shot per model. |
| `PrismOrderingWarning` | A `@scoped_validator(mode="before")` coexists with a plain `@model_validator(mode="before")` inherited from a base. pydantic runs the scoped one first (child-first), so a child that depends on the base hook sees untransformed data. Best fix: use `mode="after"` if the value derives from already-parsed fields. Otherwise call `cls.run_inherited_before(data)` at the top of the validator (the inherited hook re-runs, so must be idempotent), or `parent_ordering="acknowledged"` to assert independence. One-shot per `(class, validator)`. See [before-validator ordering](../how-to/carry-a-custom-base.md#before-validator-ordering-with-scoped_validator). |
+| `ToolSchemaDepthWarning` | `tool_schema(provider="openai", strict=True)` produced object nesting deeper than OpenAI's 5-level limit for strict structured outputs, or a recursive (self-referential) model whose depth is unbounded. The schema is returned unchanged; the API will likely reject it. Project to a shallower scope, or drop `strict=True`. See [derive an LLM tool schema](../how-to/derive-llm-tool-schema.md#depth-limit). |
diff --git a/pdm.lock b/pdm.lock
index 12711c8..519e776 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
groups = ["default", "dev"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
-content_hash = "sha256:ff1ba72e2fb39caec78aea406848421876e963ff81e3f62acc8f83dde61cd4d8"
+content_hash = "sha256:417544b8c71797d28e4258d0245894fa34d99a5d9b3a21b8563f36131d78aa10"
[[metadata.targets]]
requires_python = ">=3.12"
@@ -81,21 +81,6 @@ requires_python = ">=3.10"
summary = "Code coverage measurement for Python"
groups = ["dev"]
files = [
- {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
- {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
- {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
- {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
- {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
{file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
{file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
@@ -187,21 +172,6 @@ dependencies = [
"tomli; python_full_version <= \"3.11.0a6\"",
]
files = [
- {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
- {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
- {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
- {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
- {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
- {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
- {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
{file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
{file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
@@ -299,6 +269,21 @@ files = [
{file = "fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab"},
]
+[[package]]
+name = "genai-prices"
+version = "0.0.66"
+requires_python = ">=3.10"
+summary = "Calculate prices for calling LLM inference APIs."
+groups = ["dev"]
+dependencies = [
+ "httpx2>=2.0",
+ "pydantic>=2.10",
+]
+files = [
+ {file = "genai_prices-0.0.66-py3-none-any.whl", hash = "sha256:86b83f107c1cf04bb449a120cd8d4439ceb6843660d9128cde560eb511686d7b"},
+ {file = "genai_prices-0.0.66.tar.gz", hash = "sha256:f087dfe56da28a4c3933dcf846cf2b7111ba733cef674c0cbc66de80212bcd6b"},
+]
+
[[package]]
name = "greenlet"
version = "3.5.1"
@@ -369,6 +354,17 @@ files = [
{file = "greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829"},
]
+[[package]]
+name = "griffelib"
+version = "2.0.2"
+requires_python = ">=3.10"
+summary = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API."
+groups = ["dev"]
+files = [
+ {file = "griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1"},
+ {file = "griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e"},
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -395,6 +391,21 @@ files = [
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
+[[package]]
+name = "httpcore2"
+version = "2.4.0"
+requires_python = ">=3.10"
+summary = "A minimal low-level HTTP client."
+groups = ["dev"]
+dependencies = [
+ "h11>=0.16",
+ "truststore>=0.10",
+]
+files = [
+ {file = "httpcore2-2.4.0-py3-none-any.whl", hash = "sha256:5218779da5d6e3c2013ac706121abfb3815d450e0613495c0de50264dce58242"},
+ {file = "httpcore2-2.4.0.tar.gz", hash = "sha256:3093a8ab8980d9f910b9cb4351df9186a0ad2350a6284a9107ac9a362a584422"},
+]
+
[[package]]
name = "httpx"
version = "0.28.1"
@@ -412,6 +423,24 @@ files = [
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
+[[package]]
+name = "httpx2"
+version = "2.4.0"
+requires_python = ">=3.10"
+summary = "The next generation HTTP client."
+groups = ["dev"]
+dependencies = [
+ "anyio",
+ "httpcore2==2.4.0",
+ "idna>=3.18",
+ "truststore>=0.10",
+ "typing-extensions>=4.5.0; python_version < \"3.13\"",
+]
+files = [
+ {file = "httpx2-2.4.0-py3-none-any.whl", hash = "sha256:425acd99297829599decf6701386dd84db3542597d36d3e2e4def930ecd57fd9"},
+ {file = "httpx2-2.4.0.tar.gz", hash = "sha256:32e0734b61eb0824b3f56a9e98d6d92d381a3ef12c0045aa917ee63df6c411ef"},
+]
+
[[package]]
name = "idna"
version = "3.18"
@@ -434,6 +463,17 @@ files = [
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
+[[package]]
+name = "logfire-api"
+version = "4.36.0"
+requires_python = ">=3.10"
+summary = "Shim for the Logfire SDK which does nothing unless Logfire is installed"
+groups = ["dev"]
+files = [
+ {file = "logfire_api-4.36.0-py3-none-any.whl", hash = "sha256:e2898270969d05b654366b8179576a741065bb22a031078088045ff6ffead0e7"},
+ {file = "logfire_api-4.36.0.tar.gz", hash = "sha256:02167aa9e689f4807c633051eaf989577155553bf0f9f2a54c8c739f8a2646c6"},
+]
+
[[package]]
name = "nodeenv"
version = "1.10.0"
@@ -445,6 +485,20 @@ files = [
{file = "nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb"},
]
+[[package]]
+name = "opentelemetry-api"
+version = "1.42.1"
+requires_python = ">=3.10"
+summary = "OpenTelemetry Python API"
+groups = ["dev"]
+dependencies = [
+ "typing-extensions>=4.5.0",
+]
+files = [
+ {file = "opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714"},
+ {file = "opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716"},
+]
+
[[package]]
name = "packaging"
version = "26.2"
@@ -484,6 +538,27 @@ files = [
{file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"},
]
+[[package]]
+name = "pydantic-ai-slim"
+version = "1.107.0"
+requires_python = ">=3.10"
+summary = "Agent Framework / shim to use Pydantic with LLMs, slim package"
+groups = ["dev"]
+dependencies = [
+ "exceptiongroup>=1.2.2; python_version < \"3.11\"",
+ "genai-prices>=0.0.62",
+ "griffelib>=2.0",
+ "httpx>=0.27",
+ "opentelemetry-api>=1.28.0",
+ "pydantic-graph==1.107.0",
+ "pydantic>=2.12",
+ "typing-inspection>=0.4.0",
+]
+files = [
+ {file = "pydantic_ai_slim-1.107.0-py3-none-any.whl", hash = "sha256:1af49bbae06a6c598f72c54d4734ba377100cac493c9a05fa8e089bebeae0da6"},
+ {file = "pydantic_ai_slim-1.107.0.tar.gz", hash = "sha256:4616f689a92fcfecfecf2a7af27aca22f139a873cf6d7a8929eaeee9c0eedbb4"},
+]
+
[[package]]
name = "pydantic-core"
version = "2.46.4"
@@ -494,35 +569,6 @@ dependencies = [
"typing-extensions>=4.14.1",
]
files = [
- {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"},
- {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"},
- {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"},
- {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"},
- {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"},
- {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"},
- {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"},
- {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"},
- {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"},
- {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"},
- {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"},
- {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"},
- {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"},
- {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"},
- {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"},
- {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"},
- {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"},
{file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"},
{file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"},
{file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"},
@@ -583,17 +629,26 @@ files = [
{file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"},
{file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"},
{file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"},
- {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"},
{file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"},
]
+[[package]]
+name = "pydantic-graph"
+version = "1.107.0"
+requires_python = ">=3.10"
+summary = "Graph and state machine library"
+groups = ["dev"]
+dependencies = [
+ "httpx>=0.27",
+ "logfire-api>=3.14.1",
+ "pydantic>=2.12",
+ "typing-inspection>=0.4.0",
+]
+files = [
+ {file = "pydantic_graph-1.107.0-py3-none-any.whl", hash = "sha256:71add94fe7e14c703977a895117c475aae6c0b02a774a036c4d00d9a63c78b00"},
+ {file = "pydantic_graph-1.107.0.tar.gz", hash = "sha256:278dd89b3e33f3a2963ac949f27a53aef705c5d883a8ce5d06d23e6e3cfbd972"},
+]
+
[[package]]
name = "pygments"
version = "2.20.0"
@@ -686,29 +741,29 @@ files = [
[[package]]
name = "ruff"
-version = "0.15.16"
+version = "0.15.17"
requires_python = ">=3.7"
summary = "An extremely fast Python linter and code formatter, written in Rust."
groups = ["dev"]
files = [
- {file = "ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2"},
- {file = "ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a"},
- {file = "ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb"},
- {file = "ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951"},
- {file = "ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8"},
- {file = "ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123"},
- {file = "ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a"},
- {file = "ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3"},
- {file = "ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e"},
- {file = "ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec"},
- {file = "ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121"},
- {file = "ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78"},
+ {file = "ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f"},
+ {file = "ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7"},
+ {file = "ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719"},
+ {file = "ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4"},
+ {file = "ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7"},
+ {file = "ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f"},
+ {file = "ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f"},
+ {file = "ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8"},
+ {file = "ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392"},
+ {file = "ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084"},
+ {file = "ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456"},
+ {file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"},
]
[[package]]
@@ -773,7 +828,7 @@ files = [
[[package]]
name = "starlette"
-version = "1.2.1"
+version = "1.3.0"
requires_python = ">=3.10"
summary = "The little ASGI library that shines."
groups = ["dev"]
@@ -782,8 +837,19 @@ dependencies = [
"typing-extensions>=4.10.0; python_version < \"3.13\"",
]
files = [
- {file = "starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89"},
- {file = "starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"},
+ {file = "starlette-1.3.0-py3-none-any.whl", hash = "sha256:ff4ca1bc23de6a45cdfbbeb9b3caaea524c9221cdd8a6684ad7a4f651a83890b"},
+ {file = "starlette-1.3.0.tar.gz", hash = "sha256:bb58cbb7a699da4ee4be9ed4cdfe4bc5b0390aa6dac1d1ac714ebebe8dc3c8df"},
+]
+
+[[package]]
+name = "truststore"
+version = "0.10.4"
+requires_python = ">=3.10"
+summary = "Verify certificates using native system trust stores"
+groups = ["dev"]
+files = [
+ {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"},
+ {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"},
]
[[package]]
diff --git a/pyproject.toml b/pyproject.toml
index 4972d9a..61b0439 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -62,6 +62,7 @@ dev = [
"pytest-local-badge>=1.3.0",
"sqlmodel>=0.0.22",
"sqlalchemy>=2.0",
+ "pydantic-ai-slim>=1.0",
]
[build-system]
requires = ["pdm-backend"]
@@ -137,6 +138,12 @@ exclude_also = [
[tool.pytest.ini_options]
minversion = "8.4"
testpaths = ["tests"]
+# pydantic-ai's run_sync uses the deprecated asyncio event-loop API under
+# Python 3.13; it is the dependency's wart, not prism's, so keep it out of the
+# warnings the suite tracks. Scoped to that exact message + module.
+filterwarnings = [
+ "ignore:There is no current event loop:DeprecationWarning:pydantic_ai._utils",
+]
addopts = """
--cov --no-cov-on-fail
-v -l --color=yes
diff --git a/src/pydantic_prism/__init__.py b/src/pydantic_prism/__init__.py
index cde46fc..5b9f32c 100644
--- a/src/pydantic_prism/__init__.py
+++ b/src/pydantic_prism/__init__.py
@@ -20,6 +20,7 @@
ProjectionBaseError,
ProjectionNameError,
RefResolutionError,
+ ToolSchemaDepthWarning,
)
from .flow import (
FlowEdge,
@@ -39,6 +40,7 @@
RefShape,
)
from .scopes import Classification, Direction, In, Out
+from .toolschema import ToolProvider
from .validators import scoped_validator
try:
@@ -78,6 +80,8 @@
"ScopeExpr",
"Scoped",
"ScopedModel",
+ "ToolProvider",
+ "ToolSchemaDepthWarning",
"backref",
"build_flow_report",
"projection_diagram",
diff --git a/src/pydantic_prism/errors.py b/src/pydantic_prism/errors.py
index 5b62a33..8b224c3 100644
--- a/src/pydantic_prism/errors.py
+++ b/src/pydantic_prism/errors.py
@@ -16,6 +16,7 @@
"ProjectionBaseError",
"ProjectionNameError",
"RefResolutionError",
+ "ToolSchemaDepthWarning",
]
@@ -56,6 +57,18 @@ class PrismOrderingWarning(PrismWarning):
"""
+class ToolSchemaDepthWarning(PrismWarning):
+ """An LLM tool schema nests objects deeper than the provider allows.
+
+ Emitted by ``tool_schema(provider="openai", strict=True)`` when the
+ projection's object nesting exceeds OpenAI's 5-level limit for strict
+ structured outputs, or when a recursive (self-referential) model makes the
+ depth unbounded. prism still returns the schema unchanged — the warning
+ surfaces the likely API rejection at build time rather than as an opaque
+ 400 from the vendor. Project to a shallower scope, or drop ``strict=True``.
+ """
+
+
class EmptyProjectionError(PrismError, ValueError):
"""A projection selected zero fields.
diff --git a/src/pydantic_prism/model.py b/src/pydantic_prism/model.py
index a1eb221..9ae0ef8 100644
--- a/src/pydantic_prism/model.py
+++ b/src/pydantic_prism/model.py
@@ -28,6 +28,7 @@
if TYPE_CHECKING:
from .flow import FlowReport
+ from .toolschema import ToolProvider
__all__ = ["Projection", "ScopedModel"]
@@ -174,6 +175,55 @@ def scope(
bases=cls.__prism_bases__ if bases is None else bases,
)
+ @classmethod
+ def tool_schema(
+ cls,
+ *,
+ provider: ToolProvider = "openai",
+ strict: bool = True,
+ name: str | None = None,
+ description: str | None = None,
+ envelope: bool = True,
+ ) -> dict[str, Any]:
+ """Render this projection as an LLM tool / function schema.
+
+ The projection already hides the fields the model should not see and
+ carries any per-scope ``description`` / ``examples``; this method
+ normalizes its ``model_json_schema()`` for ``provider`` and wraps it in
+ that provider's tool envelope (ready to pass to the ``openai`` /
+ ``anthropic`` / ``mistral`` SDK — no SDK is imported; ``mistral`` uses
+ the OpenAI-compatible tools format).
+
+ ``strict=True`` (the default, and what OpenAI recommends) applies the
+ rewrites OpenAI strict structured outputs require: every object gets
+ ``additionalProperties: false`` and lists all its properties as
+ ``required``, and an optional/defaulted field becomes a ``"null"`` union
+ with its ``default`` dropped. This is the one place prism rewrites types
+ rather than only filtering fields — opt out with ``strict=False`` (e.g.
+ for Anthropic, whose ``input_schema`` is plain JSON Schema). Under
+ ``provider="openai", strict=True`` a schema that nests objects deeper
+ than 5 levels (or a recursive model) emits a
+ :class:`~pydantic_prism.ToolSchemaDepthWarning`.
+
+ ``name`` defaults to the projection class name; ``description`` falls
+ back to the projection's per-scope model description when present.
+
+ ``envelope=False`` returns just the normalized parameters schema instead
+ of the provider envelope — the shape a framework wants for its own tool
+ definition (e.g. Pydantic AI's ``ToolDefinition.parameters_json_schema``).
+ ``provider`` then only governs the OpenAI depth check.
+ """
+ from .toolschema import build
+
+ return build(
+ cls,
+ provider=provider,
+ strict=strict,
+ name=name,
+ description=description,
+ envelope=envelope,
+ )
+
class ScopedModel(BaseModel):
"""Canonical pydantic model whose fields are tagged with scopes.
@@ -512,6 +562,44 @@ def scope(
"""
return cls._build_projection(as_expr(scope), name, bases, None)
+ @classmethod
+ def tool_schema(
+ cls,
+ scope: ScopeLike | None = None,
+ *,
+ provider: ToolProvider = "openai",
+ strict: bool = True,
+ name: str | None = None,
+ description: str | None = None,
+ envelope: bool = True,
+ ) -> dict[str, Any]:
+ """Render a scope of this model as an LLM tool / function schema.
+
+ A one-step convenience equivalent to
+ ``Model.scope(scope).tool_schema(...)`` — see
+ :meth:`Projection.tool_schema` for the ``provider`` / ``strict`` /
+ ``name`` / ``description`` / ``envelope`` contract. ``scope`` falls back
+ to the model's ``default_scope=`` when omitted (and raises if neither is
+ given). For write-side (mass-assignment-safe) tool inputs, build the
+ projection explicitly instead: ``Model.input(scope).tool_schema(...)``.
+ """
+ if scope is not None:
+ expr = as_expr(scope)
+ elif cls.__prism_default_scope__ is not None:
+ expr = cls.__prism_default_scope__
+ else:
+ raise TypeError(
+ f"{cls.__name__}.tool_schema() requires a scope, or a "
+ f"default_scope= on the model"
+ )
+ return cls.scope(expr).tool_schema(
+ provider=provider,
+ strict=strict,
+ name=name,
+ description=description,
+ envelope=envelope,
+ )
+
@classmethod
def _build_projection(
cls,
diff --git a/src/pydantic_prism/toolschema.py b/src/pydantic_prism/toolschema.py
new file mode 100644
index 0000000..c7b6fd9
--- /dev/null
+++ b/src/pydantic_prism/toolschema.py
@@ -0,0 +1,277 @@
+"""Derive LLM tool / function schemas from a projection.
+
+A projection already filters fields and carries per-scope ``description`` /
+``examples`` (see :mod:`pydantic_prism._internal.model.schema`), so its
+``model_json_schema()`` is most of an LLM tool schema already. This module is
+the thin remainder: it **normalizes** that schema for a provider's strict mode
+and wraps it in the provider's tool envelope.
+
+The normalization is the one place prism rewrites types rather than only
+filtering fields — and it is gated behind an explicit ``strict=True``. OpenAI
+strict structured outputs require every property to be ``required``, forbid
+``default``, and express optionality as a ``"null"`` union; ``strict`` applies
+exactly those rewrites. Anthropic tool ``input_schema`` is plain JSON Schema and
+needs none of them, so a non-strict call leaves the schema as pydantic emitted
+it. Mistral uses the OpenAI-compatible tools format, so it shares the OpenAI
+envelope; the OpenAI-specific 5-level depth check is *not* applied to it.
+
+No vendor SDK is imported: every function returns plain ``dict`` objects that
+the caller hands to ``openai`` / ``anthropic`` / ``mistral`` themselves.
+"""
+
+from __future__ import annotations
+
+import warnings
+from typing import TYPE_CHECKING, Any, Literal, cast
+
+from .errors import ToolSchemaDepthWarning
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from pydantic import BaseModel
+
+__all__ = ["ToolProvider"]
+
+ToolProvider = Literal["openai", "anthropic", "mistral"]
+
+# OpenAI strict structured outputs allow object nesting up to this depth.
+_OPENAI_MAX_DEPTH = 5
+
+# Schema keywords that describe a field rather than constrain its value; they
+# stay outside the nullable union so the description survives the rewrite.
+_META_KEYS = frozenset(
+ {"description", "title", "examples", "deprecated", "readOnly", "writeOnly"}
+)
+
+# How a key's value carries nested schemas: a mapping of them, a list of them,
+# or a single one. ``$defs``/``properties`` map names to schemas.
+_MAP_KEYS = ("$defs", "properties")
+_LIST_KEYS = ("anyOf", "oneOf", "allOf", "prefixItems")
+_SINGLE_KEYS = ("items", "additionalProperties", "not")
+
+
+def build(
+ projection: type[BaseModel],
+ *,
+ provider: ToolProvider,
+ strict: bool,
+ name: str | None,
+ description: str | None,
+ envelope: bool,
+) -> dict[str, Any]:
+ """Normalize a projection's JSON schema, optionally wrapping it in an envelope.
+
+ With ``envelope=True`` (the default) returns the provider's tool/function
+ envelope; with ``envelope=False`` returns just the normalized parameters
+ schema — the shape a framework wants for its own tool definition (e.g.
+ Pydantic AI's ``ToolDefinition.parameters_json_schema``).
+ """
+ schema = projection.model_json_schema()
+ schema.pop("title", None)
+ schema_description = schema.pop("description", None)
+ # A projection's auto __doc__ ("Projection of X to scope Y.") is an internal
+ # artifact, not a tool description; ignore it and fall back to the canonical
+ # model's own docstring. A *deliberate* per-scope description (set via the
+ # vary-schema mechanism) differs from __doc__ and is kept.
+ if schema_description == projection.__doc__:
+ source = getattr(projection, "__prism_source__", None)
+ schema_description = getattr(source, "__doc__", None)
+ tool_name = name or projection.__name__
+ tool_description = description or schema_description
+ if strict:
+ _strictify(schema)
+ if provider == "openai" and strict:
+ _check_depth(schema, tool_name)
+ if not envelope:
+ return schema
+ return _envelope(
+ schema,
+ provider=provider,
+ name=tool_name,
+ description=tool_description,
+ strict=strict,
+ )
+
+
+def _envelope(
+ schema: dict[str, Any],
+ *,
+ provider: ToolProvider,
+ name: str,
+ description: str | None,
+ strict: bool,
+) -> dict[str, Any]:
+ """Wrap a normalized schema in the provider's tool/function envelope."""
+ if provider in ("openai", "mistral"):
+ # Mistral uses the OpenAI-compatible tools format: a `type: function`
+ # wrapper with name/description/parameters and an optional `strict` flag.
+ function: dict[str, Any] = {
+ "name": name,
+ "parameters": schema,
+ "strict": strict,
+ }
+ if description:
+ function["description"] = description
+ return {"type": "function", "function": function}
+ if provider == "anthropic":
+ tool: dict[str, Any] = {"name": name, "input_schema": schema}
+ if description:
+ tool["description"] = description
+ return tool
+ raise ValueError(f"unknown tool provider: {provider!r}") # noqa: TRY003
+
+
+def _as_dict(value: Any) -> dict[str, Any] | None:
+ """Narrow a JSON value to a schema dict (typed), or None."""
+ return cast("dict[str, Any]", value) if isinstance(value, dict) else None
+
+
+def _as_list(value: Any) -> list[Any] | None:
+ """Narrow a JSON value to a list (typed), or None."""
+ return cast("list[Any]", value) if isinstance(value, list) else None
+
+
+def _subschemas(schema: dict[str, Any]) -> Iterator[dict[str, Any]]:
+ """Yield every nested schema dict reachable one level below ``schema``."""
+ for key in _MAP_KEYS:
+ mapping = _as_dict(schema.get(key))
+ for value in mapping.values() if mapping else ():
+ child = _as_dict(value)
+ if child is not None:
+ yield child
+ for child in _children(schema):
+ yield child
+
+
+def _children(schema: dict[str, Any]) -> Iterator[dict[str, Any]]:
+ """Yield nested schemas that do not add an object-nesting level."""
+ for key in _LIST_KEYS:
+ items = _as_list(schema.get(key))
+ for value in items or ():
+ child = _as_dict(value)
+ if child is not None:
+ yield child
+ for key in _SINGLE_KEYS:
+ child = _as_dict(schema.get(key))
+ if child is not None:
+ yield child
+
+
+def _strictify(schema: dict[str, Any]) -> None:
+ """Recursively rewrite ``schema`` in place to satisfy OpenAI strict mode.
+
+ Every object gets ``additionalProperties: false`` and lists *all* its
+ properties as ``required``; any property that was optional or carried a
+ ``default`` is made nullable and its ``default`` dropped. ``$ref`` strings
+ are never followed (recursive models do not loop) — each ``$defs`` entry is
+ normalized once where it is defined.
+ """
+ properties = _as_dict(schema.get("properties"))
+ if properties is not None:
+ required = set(schema.get("required", []))
+ for field_name, value in properties.items():
+ # A JSON Schema "properties" value is always an object schema.
+ field = cast("dict[str, Any]", value)
+ optional = field_name not in required or "default" in field
+ field.pop("default", None)
+ if optional:
+ _make_nullable(field)
+ schema["required"] = list(properties)
+ schema["additionalProperties"] = False
+ for subschema in _subschemas(schema):
+ _strictify(subschema)
+
+
+def _make_nullable(schema: dict[str, Any]) -> None:
+ """Add ``{"type": "null"}`` to ``schema``'s type union, in place."""
+ any_of = _as_list(schema.get("anyOf"))
+ if any_of is not None:
+ if {"type": "null"} not in any_of:
+ any_of.append({"type": "null"})
+ return
+ meta = {key: schema.pop(key) for key in list(schema) if key in _META_KEYS}
+ inner = {key: schema.pop(key) for key in list(schema)}
+ schema["anyOf"] = [inner, {"type": "null"}]
+ schema.update(meta)
+
+
+def _check_depth(schema: dict[str, Any], name: str) -> None:
+ """Warn when object nesting exceeds OpenAI's strict-mode depth limit."""
+ defs: dict[str, Any] = _as_dict(schema.get("$defs")) or {}
+ depth, path, recursive = _object_depth(schema, defs, frozenset(), [])
+ if recursive:
+ warnings.warn(
+ f"Tool schema {name!r} references a recursive (self-referential) model "
+ f"(path: {' → '.join(path) or ''}); OpenAI strict structured "
+ f"outputs cannot express unbounded nesting. The schema is returned "
+ f"unchanged; the API will likely reject it.",
+ ToolSchemaDepthWarning,
+ stacklevel=3,
+ )
+ elif depth > _OPENAI_MAX_DEPTH:
+ warnings.warn(
+ f"Tool schema {name!r} nests objects {depth} levels deep "
+ f"(path: {' → '.join(path)}); OpenAI strict structured outputs allow at "
+ f"most {_OPENAI_MAX_DEPTH}. The schema is returned unchanged; the API "
+ f"will likely reject it.",
+ ToolSchemaDepthWarning,
+ stacklevel=3,
+ )
+
+
+def _object_depth(
+ node: dict[str, Any],
+ defs: dict[str, Any],
+ active: frozenset[str],
+ path: list[str],
+) -> tuple[int, list[str], bool]:
+ """Max object-nesting depth reachable from ``node``, resolving ``$ref``.
+
+ Returns ``(depth, deepest_path, recursive)``. ``active`` is the set of
+ ``$defs`` names on the current resolution path; re-entering one means the
+ model is recursive (its depth is unbounded under strict mode).
+ """
+ ref = node.get("$ref")
+ if isinstance(ref, str):
+ return _ref_depth(ref, defs, active, path)
+
+ best_depth, best_path, recursive = 0, path, False
+
+ properties = _as_dict(node.get("properties"))
+ if properties is not None:
+ for field_name, value in properties.items():
+ # A JSON Schema "properties" value is always an object schema.
+ field = cast("dict[str, Any]", value)
+ depth, sub_path, sub_recursive = _object_depth(
+ field, defs, active, [*path, str(field_name)]
+ )
+ recursive = recursive or sub_recursive
+ if depth + 1 > best_depth:
+ best_depth, best_path = depth + 1, sub_path
+ # An object with no nested objects is still one level of nesting.
+ best_depth = max(best_depth, 1)
+
+ for child in _children(node):
+ depth, sub_path, sub_recursive = _object_depth(child, defs, active, path)
+ recursive = recursive or sub_recursive
+ if depth > best_depth:
+ best_depth, best_path = depth, sub_path
+
+ return best_depth, best_path, recursive
+
+
+def _ref_depth(
+ ref: str,
+ defs: dict[str, Any],
+ active: frozenset[str],
+ path: list[str],
+) -> tuple[int, list[str], bool]:
+ """Depth of a ``$ref`` target, flagging a recursive (re-entered) one."""
+ target_name = ref.rsplit("/", 1)[-1]
+ if target_name in active:
+ return 0, path, True
+ target = _as_dict(defs.get(target_name))
+ if target is None: # pragma: no cover - malformed $ref
+ return 0, path, False
+ return _object_depth(target, defs, active | {target_name}, path)
diff --git a/tests/test_docs.py b/tests/test_docs.py
index 7232edb..a1cb953 100644
--- a/tests/test_docs.py
+++ b/tests/test_docs.py
@@ -60,6 +60,8 @@ def test_relative_links_resolve(md: Path) -> None:
"docs/how-to/prevent-mass-assignment.md",
"docs/how-to/carry-a-custom-base.md",
"docs/how-to/vary-schema-per-scope.md",
+ "docs/how-to/derive-llm-tool-schema.md",
+ "docs/how-to/use-with-pydantic-ai.md",
"docs/how-to/export-diagrams.md",
"docs/how-to/use-with-fastapi.md",
]
diff --git a/tests/test_toolschema.py b/tests/test_toolschema.py
new file mode 100644
index 0000000..cff313b
--- /dev/null
+++ b/tests/test_toolschema.py
@@ -0,0 +1,361 @@
+"""Tests for LLM tool-schema derivation (``tool_schema`` + ``toolschema``)."""
+
+import warnings
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import Annotated, Optional, Union, cast
+
+import pytest
+
+from pydantic_prism import (
+ Scope,
+ ScopedModel,
+ ToolProvider,
+ ToolSchemaDepthWarning,
+ scoped,
+)
+
+
+@contextmanager
+def no_depth_warning() -> Iterator[None]:
+ """Assert no ``ToolSchemaDepthWarning`` is emitted in the block."""
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ yield
+ assert not [w for w in caught if issubclass(w.category, ToolSchemaDepthWarning)]
+
+
+class Public(Scope): ...
+
+
+class Internal(Public): ...
+
+
+class Address(ScopedModel):
+ street: Annotated[str, scoped(Public)]
+ note: Annotated[Optional[str], scoped(Public)] = None # noqa: UP045
+
+
+class User(ScopedModel):
+ """A user."""
+
+ id: Annotated[int, scoped(Public)]
+ email: Annotated[str, scoped(Internal)]
+ nickname: Annotated[Optional[str], scoped(Public)] = None # noqa: UP045
+ address: Annotated[Optional[Address], scoped(Public)] = None # noqa: UP045
+
+
+# --- envelopes -------------------------------------------------------------
+
+
+def test_openai_strict_envelope() -> None:
+ tool = User.tool_schema(Public, provider="openai")
+ assert tool["type"] == "function"
+ fn = tool["function"]
+ assert fn["name"] == "UserPublic"
+ assert fn["strict"] is True
+ # Falls back to the canonical model's docstring, not the auto projection doc.
+ assert fn["description"] == "A user."
+ params = fn["parameters"]
+ assert params["additionalProperties"] is False
+ # Every property is required, and the hidden Internal field is absent.
+ assert set(params["required"]) == {"id", "nickname", "address"}
+ assert "email" not in params["properties"]
+
+
+def test_anthropic_envelope_shape() -> None:
+ tool = User.scope(Public).tool_schema(provider="anthropic", strict=False)
+ assert set(tool) == {"name", "input_schema", "description"}
+ assert tool["name"] == "UserPublic"
+ assert tool["input_schema"]["type"] == "object"
+
+
+def test_mistral_uses_openai_compatible_envelope() -> None:
+ mistral = User.tool_schema(Public, provider="mistral")
+ openai = User.tool_schema(Public, provider="openai")
+ # Mistral shares the OpenAI tools format exactly.
+ assert mistral == openai
+ assert mistral["type"] == "function"
+ assert mistral["function"]["strict"] is True
+
+
+def test_mistral_non_strict_leaves_schema_faithful() -> None:
+ fn = User.tool_schema(Public, provider="mistral", strict=False)["function"]
+ assert fn["strict"] is False
+ assert "additionalProperties" not in fn["parameters"]
+
+
+def test_unknown_provider_raises() -> None:
+ with pytest.raises(ValueError, match="unknown tool provider"):
+ User.scope(Public).tool_schema(provider=cast(ToolProvider, "bogus"))
+
+
+# --- strict normalization --------------------------------------------------
+
+
+def test_strict_marks_optionals_nullable_and_drops_default() -> None:
+ params = User.tool_schema(Public, provider="openai")["function"]["parameters"]
+ nickname = params["properties"]["nickname"]
+ assert {"type": "null"} in nickname["anyOf"]
+ assert "default" not in nickname
+
+
+def test_strict_recurses_into_defs() -> None:
+ params = User.tool_schema(Public, provider="openai")["function"]["parameters"]
+ address_def = params["$defs"]["AddressPublic"]
+ assert address_def["additionalProperties"] is False
+ assert set(address_def["required"]) == {"street", "note"}
+
+
+def test_strict_recurses_into_array_items() -> None:
+ class Tagged(ScopedModel):
+ tags: Annotated[list[Address], scoped(Public)]
+
+ params = Tagged.tool_schema(Public, provider="openai")["function"]["parameters"]
+ assert params["properties"]["tags"]["type"] == "array"
+ # The element model in $defs is strictified too.
+ assert params["$defs"]["AddressPublic"]["additionalProperties"] is False
+
+
+def test_non_strict_leaves_required_untouched() -> None:
+ params = User.scope(Public).tool_schema(provider="anthropic", strict=False)[
+ "input_schema"
+ ]
+ # pydantic only requires fields without a default.
+ assert params["required"] == ["id"]
+ assert "additionalProperties" not in params
+
+
+def test_bare_type_optional_is_wrapped() -> None:
+ class Counter(ScopedModel):
+ n: Annotated[int, scoped(Public)] = 0
+
+ n = Counter.tool_schema(Public, provider="openai")["function"]["parameters"][
+ "properties"
+ ]["n"]
+ assert n["anyOf"] == [{"type": "integer"}, {"type": "null"}]
+ assert "default" not in n
+
+
+def test_union_optional_appends_null() -> None:
+ class Mix(ScopedModel):
+ v: Annotated[Union[str, int], scoped(Public)] = "x" # noqa: UP007
+
+ v = Mix.tool_schema(Public, provider="openai")["function"]["parameters"][
+ "properties"
+ ]["v"]
+ assert {"type": "null"} in v["anyOf"]
+ # The original members survive alongside the injected null.
+ assert {"type": "string"} in v["anyOf"]
+ assert {"type": "integer"} in v["anyOf"]
+
+
+# --- envelope=False (bare parameters schema) -------------------------------
+
+
+def test_envelope_false_returns_bare_parameters() -> None:
+ bare = User.tool_schema(Public, provider="openai", envelope=False)
+ enveloped = User.tool_schema(Public, provider="openai")
+ # The bare schema is exactly what the envelope nests under "parameters".
+ assert bare == enveloped["function"]["parameters"]
+ assert bare["type"] == "object"
+ assert "function" not in bare
+
+
+def test_envelope_false_still_normalizes_under_strict() -> None:
+ bare = User.tool_schema(Public, provider="openai", envelope=False, strict=True)
+ assert bare["additionalProperties"] is False
+ assert set(bare["required"]) == {"id", "nickname", "address"}
+
+
+def test_envelope_false_non_strict_is_faithful() -> None:
+ bare = User.tool_schema(Public, provider="anthropic", envelope=False, strict=False)
+ assert bare["required"] == ["id"]
+ assert "additionalProperties" not in bare
+
+
+def test_envelope_false_still_warns_on_depth() -> None:
+ class N6(ScopedModel):
+ v: Annotated[str, scoped(Public)]
+
+ class N5(ScopedModel):
+ c: Annotated[N6, scoped(Public)]
+
+ class N4(ScopedModel):
+ c: Annotated[N5, scoped(Public)]
+
+ class N3(ScopedModel):
+ c: Annotated[N4, scoped(Public)]
+
+ class N2(ScopedModel):
+ c: Annotated[N3, scoped(Public)]
+
+ class N1(ScopedModel):
+ c: Annotated[N2, scoped(Public)]
+
+ with pytest.warns(ToolSchemaDepthWarning):
+ N1.tool_schema(Public, provider="openai", envelope=False)
+
+
+# --- name / description sourcing -------------------------------------------
+
+
+def test_name_and_description_override() -> None:
+ fn = User.tool_schema(
+ Public, provider="openai", name="create_user", description="Make a user."
+ )["function"]
+ assert fn["name"] == "create_user"
+ assert fn["description"] == "Make a user."
+
+
+def test_description_omitted_when_absent() -> None:
+ class Bare(ScopedModel):
+ x: Annotated[int, scoped(Public)]
+
+ fn = Bare.tool_schema(Public, provider="openai")["function"]
+ assert "description" not in fn
+
+
+def test_auto_projection_doc_never_leaks_as_description() -> None:
+ fn = User.tool_schema(Public, provider="openai")["function"]
+ assert "Projection of" not in fn["description"]
+
+
+def test_per_scope_model_description_is_kept() -> None:
+ # A description on the Scope class lands on the projection root; it is a
+ # deliberate, meaningful tool description and must win over the canonical doc.
+ class Tool(Scope, description="Look up the weather."): ...
+
+ class Weather(ScopedModel):
+ """internal docstring, should not be used"""
+
+ city: Annotated[str, scoped(Tool)]
+
+ fn = Weather.tool_schema(Tool, provider="openai")["function"]
+ assert fn["description"] == "Look up the weather."
+
+
+# --- API equivalence + composition -----------------------------------------
+
+
+def test_convenience_matches_projection_method() -> None:
+ assert User.tool_schema(Public, provider="openai") == User.scope(
+ Public
+ ).tool_schema(provider="openai")
+
+
+def test_composes_with_input_output() -> None:
+ # input() drops read-only fields; tool_schema works on any projection.
+ schema = User.input(Public).tool_schema(provider="openai")["function"]["parameters"]
+ assert schema["additionalProperties"] is False
+
+
+def test_default_scope_fallback() -> None:
+ class Doc(ScopedModel, default_scope=Public):
+ body: Annotated[str, scoped(Public)]
+ secret: Annotated[str, scoped(Internal)]
+
+ fn = Doc.tool_schema(provider="openai")["function"]
+ assert "secret" not in fn["parameters"]["properties"]
+
+
+def test_missing_scope_without_default_raises() -> None:
+ with pytest.raises(TypeError, match="requires a scope"):
+ User.tool_schema()
+
+
+# --- depth warning ---------------------------------------------------------
+
+
+def test_shallow_model_does_not_warn() -> None:
+ with no_depth_warning():
+ User.tool_schema(Public, provider="openai")
+
+
+def test_deep_nesting_warns() -> None:
+ class N6(ScopedModel):
+ v: Annotated[str, scoped(Public)]
+
+ class N5(ScopedModel):
+ c: Annotated[N6, scoped(Public)]
+
+ class N4(ScopedModel):
+ c: Annotated[N5, scoped(Public)]
+
+ class N3(ScopedModel):
+ c: Annotated[N4, scoped(Public)]
+
+ class N2(ScopedModel):
+ c: Annotated[N3, scoped(Public)]
+
+ class N1(ScopedModel):
+ c: Annotated[N2, scoped(Public)]
+
+ with pytest.warns(ToolSchemaDepthWarning, match="levels deep"):
+ N1.tool_schema(Public, provider="openai")
+
+
+def test_recursive_model_warns() -> None:
+ class Node(ScopedModel):
+ name: Annotated[str, scoped(Public)]
+ child: Annotated[Optional["Node"], scoped(Public)] = None # noqa: UP045
+
+ Node.model_rebuild()
+ with pytest.warns(ToolSchemaDepthWarning, match="recursive"):
+ Node.tool_schema(Public, provider="openai")
+
+
+def test_depth_not_checked_without_strict_or_for_other_providers() -> None:
+ class N6(ScopedModel):
+ v: Annotated[str, scoped(Public)]
+
+ class N5(ScopedModel):
+ c: Annotated[N6, scoped(Public)]
+
+ class N4(ScopedModel):
+ c: Annotated[N5, scoped(Public)]
+
+ class N3(ScopedModel):
+ c: Annotated[N4, scoped(Public)]
+
+ class N2(ScopedModel):
+ c: Annotated[N3, scoped(Public)]
+
+ class N1(ScopedModel):
+ c: Annotated[N2, scoped(Public)]
+
+ with no_depth_warning():
+ N1.tool_schema(Public, provider="openai", strict=False)
+ N1.tool_schema(Public, provider="anthropic")
+ # The 5-level limit is OpenAI-specific; Mistral is not subject to it.
+ N1.tool_schema(Public, provider="mistral")
+
+
+# --- valid-against-OpenAI-constraints self-check ---------------------------
+
+
+def _assert_openai_strict_valid(schema: dict, defs: dict) -> None:
+ """Every object lists all properties as required + forbids extras."""
+ if schema.get("type") == "object" or "properties" in schema:
+ props = schema.get("properties", {})
+ assert schema.get("additionalProperties") is False
+ assert set(schema.get("required", [])) == set(props)
+ for sub in props.values():
+ assert "default" not in sub
+ for key in ("anyOf", "oneOf", "allOf", "prefixItems"):
+ for sub in schema.get(key, []):
+ _assert_openai_strict_valid(sub, defs)
+ for key in ("items", "properties"):
+ value = schema.get(key)
+ if isinstance(value, dict):
+ children = value.values() if key == "properties" else [value]
+ for child in children:
+ _assert_openai_strict_valid(child, defs)
+
+
+def test_emitted_strict_schema_satisfies_openai_constraints() -> None:
+ params = User.tool_schema(Public, provider="openai")["function"]["parameters"]
+ defs = params.get("$defs", {})
+ _assert_openai_strict_valid(params, defs)
+ for definition in defs.values():
+ _assert_openai_strict_valid(definition, defs)