From 9cf15fde0502ec8b37ac82ba994e38532f3625e5 Mon Sep 17 00:00:00 2001 From: Ilja Orlovs Date: Thu, 11 Jun 2026 19:12:33 +0100 Subject: [PATCH] x --- README.md | 3 +- ROADMAP.md | 6 +- docs/README.md | 2 + docs/badges/duration.svg | 8 +- docs/badges/last-run.svg | 8 +- docs/badges/tests.svg | 8 +- docs/badges/warnings.svg | 8 +- docs/how-to/README.md | 5 + docs/how-to/derive-llm-tool-schema.md | 148 +++++++++++ docs/how-to/use-with-pydantic-ai.md | 123 +++++++++ docs/reference/api.md | 2 + docs/reference/errors.md | 4 +- pdm.lock | 246 +++++++++++------- pyproject.toml | 7 + src/pydantic_prism/__init__.py | 4 + src/pydantic_prism/errors.py | 13 + src/pydantic_prism/model.py | 88 +++++++ src/pydantic_prism/toolschema.py | 277 ++++++++++++++++++++ tests/test_docs.py | 2 + tests/test_toolschema.py | 361 ++++++++++++++++++++++++++ 20 files changed, 1211 insertions(+), 112 deletions(-) create mode 100644 docs/how-to/derive-llm-tool-schema.md create mode 100644 docs/how-to/use-with-pydantic-ai.md create mode 100644 src/pydantic_prism/toolschema.py create mode 100644 tests/test_toolschema.py 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)