Skip to content

Commit ac822d3

Browse files
Implement proposal 0025: tool_choice + absorb 0026 §8.X template (#70)
* Add tool_choice + ForceTool (proposal 0025) Provider.complete() gains an optional tool_choice parameter — one of "auto", "required", "none", or a ForceTool record — constraining the model's tool-calling behavior. Pre-send validation routes the three §5 failure modes through ProviderInvalidRequest (§7's existing category; no new category per the proposal's framing). ForceTool is a frozen Pydantic model with type: Literal["tool"] matching the spec discriminator. The OpenAI wire mapping in _build_request_body translates the spec shape to OpenAI's body per §8.1.1: string literals pass through verbatim; ForceTool renames type to "function" and nests the name under a function sub-object. None / omit preserves pre-0025 behavior — the field is absent on the wire and the provider's own default applies. 15 unit tests cover the three validation rules, ForceTool shape constraints (frozen, extras-forbid, Literal type), and the wire mapping rows from §8.1.1. * Extend conformance harness for tool_choice fixtures Adds assert_tool_choice_absent matcher mirroring the existing response_format_absent pattern, _build_tool_choice parser handling both YAML shapes (string for the three modes, dict for the ForceTool record form), and tool_choice passthrough on both call sites (raises path and success path). The expected_wire_request_checks dispatcher gains a tool_choice_absent key for fixture 029's default case, where the wire body MUST omit the field entirely (preserves pre-0025 caller behavior — the OpenAI provider's own default applies). LlmCallSpec uses _AllowExtras so tool_choice parses without an explicit pydantic field; no fixture-parsing model changes needed. * Bump spec to v0.20.1; refresh docs and CHANGELOG Submodule pin advances from v0.19.0 to v0.20.1, absorbing proposal 0025 (tool_choice, v0.20.0) and proposal 0026 (§8.X wire-format mapping subsection template, v0.20.1). 0026 is purely textual; the existing OpenAI §8.1 mapping is the template's reference shape so no python module-level work is needed. Runtime spec_version pins in pyproject and __init__ updated to match; smoke test asserts v0.20.1. CHANGELOG Unreleased section gains tool_choice + ForceTool + ToolChoice + validate_tool_choice entries under Added; the Provider.complete() signature extension noted under Changed; cumulative pin-bump summary updated to v0.17.0 -> v0.20.1 across six spec versions absorbed this cycle. docs/concepts/llms.md gains a "Controlling tool-call behavior with tool_choice" subsection under Tool calling, covering the four modes, the three pre-send validation rules, and the cross-provider caveat (not all providers honor tool_choice). * Drop speculative future-providers reference from CHANGELOG The "future Anthropic / Gemini providers will follow the §8.X template" framing references unproposed spec work. Out-of-scope items belong in PR descriptions scoped to this PR; speculative future capabilities and proposal numbers don't belong in the release-notes record. * Address PR #70 review: validate tool_choice strings + reword CHANGELOG validate_tool_choice now rejects unknown string literals at the API boundary. Pyright catches the well-formed path via the ToolChoice Literal alias, but Python doesn't enforce Literal at runtime — an untyped caller passing `tool_choice="bogus"` previously fell through to the wire and yielded a hard-to-debug provider-side 4xx. Now surfaces as ProviderInvalidRequest with the allowed values listed. CHANGELOG entry on the Provider.complete() signature change reworded to be technically accurate: third-party Provider implementations MUST accept the new parameter for Protocol conformance under strict typing (and to avoid TypeError at the call site); they MAY ignore it in their wire-body emission, which is how "provider doesn't honor tool_choice" looks at the impl level. The "silently drops" framing was wrong — accept-and-ignore is the correct pattern. * Address PR #70 round 2: reject non-str / non-ForceTool tool_choice validate_tool_choice now rejects values that are neither a string nor a ForceTool instance at the API boundary. A caller hand-building a dict like {"type": "tool", "name": X} (instead of constructing a ForceTool) previously fell through validation and the raw dict ended up on the wire — the spec→wire rename only runs on actual ForceTool instances, so OpenAI got the wrong request shape and rejected with a hard-to-debug 4xx. Surfaces at the boundary with the expected type listed: ProviderInvalidRequest('tool_choice must be one of "auto" / "required" / "none" or a ForceTool instance, got <type>'). Adds a unit test covering dict, int, and list inputs. The reportUnnecessaryIsInstance suppression on the ForceTool isinstance check is load-bearing — pyright narrows the type via the alias, but the entire point of this validator is to defend against untyped callers that bypass static narrowing at runtime.
1 parent a3a22c6 commit ac822d3

14 files changed

Lines changed: 513 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
88

99
### Added
1010

11+
- **`tool_choice` parameter on `Provider.complete()`** (proposal 0025, accepted in spec v0.20.0). Optional discriminated-union value constraining the model's tool-calling behavior — one of `"auto"`, `"required"`, `"none"`, or a `ForceTool(name=...)` record. Validation runs pre-send: `"required"` and `ForceTool` both demand non-empty `tools`, and `ForceTool.name` must appear in the supplied list; violations raise `ProviderInvalidRequest` (§7's existing category — no new error category). When `tool_choice` is `None` (the default) the wire field is omitted and the provider's own default applies, preserving pre-0025 behavior exactly. The `OpenAIProvider` maps the spec shape onto OpenAI's wire shape per §8.1.1 (the `ForceTool.type="tool"` renames to wire `type="function"`).
12+
- **`ForceTool` and `ToolChoice` public types** at `openarmature.llm.ForceTool` / `openarmature.llm.ToolChoice`. `ForceTool` is a frozen Pydantic model with `type: Literal["tool"] = "tool"` and `name: str`; `ToolChoice = Literal["auto", "required", "none"] | ForceTool` is the type alias used in `Provider.complete()`'s signature.
13+
- **`validate_tool_choice` public validator** at `openarmature.llm.validate_tool_choice`. Standalone validator covering the three §5 pre-send rules; useful for third-party `Provider` implementations that want to reuse the canonical validation logic.
1114
- **Bounded drain timeout on `CompiledGraph.drain()`** (proposal 0010, accepted in spec v0.19.0). `drain()` accepts an optional `timeout: float | None = None` parameter (non-negative seconds). When supplied, drain returns no later than the deadline; any observer events still queued or in-flight are reported as undelivered. Workers are cancelled cleanly so the compiled graph remains usable for subsequent invocations — partial delivery state from one drain does NOT leak into the next. Solves the "slow / hung / misbehaving observer blocks process exit" footgun for short-lived processes (CLIs, scripts, serverless functions). Observers SHOULD be cancellation-safe (idempotent writes, `try/finally` cleanup); the spec doesn't mandate it but the docs recommend it.
1215
- **`DrainSummary` frozen dataclass** at `openarmature.graph.DrainSummary`. Returned from every `drain()` call (with or without `timeout`). Fields: `undelivered_count: int`, `timeout_reached: bool`. The shape is consistent across timed and untimed drains — callers receive the same dataclass whether the timeout was supplied or not. Per the v0.19.0 contract the two declared fields are the spec-mandated minimum; richer diagnostic detail (per-observer counts, sampled event metadata) is reserved for follow-on PRs.
1316
- **Per-instance fan-out resume contract** (proposal 0009, accepted in spec v0.18.0). The engine now writes a checkpoint record at every `completed` event inside a fan-out instance (in addition to the existing outermost-graph + subgraph-internal + fan-out node completion saves). On resume the engine consults the saved record's `fan_out_progress` field and treats each instance as `completed` (skip, contribution rolls forward), `in_flight` (re-run from subgraph entry), or `not_started` (dispatch normally). The `append` reducer's no-double-merge guarantee holds across resume because `completed` is a one-shot accumulator state.
@@ -17,13 +20,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1720

1821
### Changed
1922

23+
- **`Provider.complete()` signature** extended with an optional `tool_choice: ToolChoice | None = None` parameter (per proposal 0025 v0.20.0). Backward-compatible: callers that omit the new argument see no wire-shape change. Third-party `Provider` implementations MUST add the parameter to remain Protocol-conformant under strict type checking (and to accept calls that pass `tool_choice` without raising `TypeError`); they MAY ignore it in their wire-body emission, which is how "provider doesn't honor tool_choice" looks at the impl level. The `OpenAIProvider` wire mapping is implemented per §8.1.1.
2024
- **`CompiledGraph.drain()` return type** changed from `None` to `DrainSummary` (pre-1.0; per proposal 0010 v0.19.0 contract). Callers that ignored the return are unaffected — `await graph.drain()` discards the returned dataclass exactly as before. Callers that explicitly typed the return as `None` will need to update their annotation.
2125
- **Fan-out resume behavior** flipped from atomic restart (0008's v1 contract) to per-instance resume. A crash mid-fan-out used to re-run the entire fan-out on resume; now only the instances that did not complete-and-record their contribution re-run. The economics matter for large fan-outs of expensive work (LLM calls, long extractions): an 80% complete fan-out crash now restores 80% of its results rather than discarding them.
2226
- **`SQLiteCheckpointer` schema** picks up a new `fan_out_progress_blob` column (added via `ALTER TABLE` for backward compatibility with pre-0009 databases). Pre-0009 rows back-fill as NULL on load and round-trip as the empty-tuple default. Both `pickle` and `json` serialization modes round-trip the new field.
2327

2428
### Notes
2529

26-
- **Pinned spec version bumped from v0.17.0 to v0.19.0 over this Unreleased cycle.** Four spec versions absorbed: v0.17.1 (proposal 0019, multi-provider wire-format extension; purely textual reframe of llm-provider §8 as a catalog of wire-format mappings, OpenAI-compatible body nested under §8.1, code references updated to §8.1 / §8.1.1 / §8.1.2 / §8.1.3 / §8.1.5.1 / §8.1.1.1), v0.18.0 (proposal 0009, per-instance fan-out resume; pipeline-utilities §10.3 / §10.7 revised, §10.11 added with per-instance state machine plus composition rules plus configurable batching; the `append` reducer no-double-merge invariant from §10.11.1 is the load-bearing correctness story; see Added / Changed above), v0.18.1 (fixture-only patch on `release/v0.18.1` correcting an off-by-one literal in fixture 052's expected `results`), and v0.19.0 (proposal 0010, bounded drain timeout; graph-engine §6 amended with the `timeout` parameter and `DrainSummary` return contract; see Added / Changed above). All existing conformance fixtures continue to pass.
30+
- **Pinned spec version bumped from v0.17.0 to v0.20.1 over this Unreleased cycle.** Six spec versions absorbed: v0.17.1 (proposal 0019, multi-provider wire-format extension — purely textual reframe of llm-provider §8 as a catalog of wire-format mappings; OpenAI-compatible body nested under §8.1), v0.18.0 (proposal 0009, per-instance fan-out resume — pipeline-utilities §10.3 / §10.7 revised, §10.11 added; the `append` reducer no-double-merge invariant is the load-bearing correctness story), v0.18.1 (fixture-only patch correcting an off-by-one literal in fixture 052's expected `results`), v0.19.0 (proposal 0010, bounded drain timeout — graph-engine §6 amended with the `timeout` parameter and `DrainSummary` return contract), v0.20.0 (proposal 0025, llm-provider `tool_choice` — §5 / §7 / §8.1.1 amended; see Added / Changed above), and v0.20.1 (proposal 0026, llm-provider §8.X wire-format mapping subsection template — purely textual §8 framing paragraph; the existing OpenAI §8.1 mapping is the template's reference shape so no python module-level work was needed). All existing conformance fixtures continue to pass.
2731

2832
## [0.8.0] — 2026-05-23
2933

docs/concepts/llms.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,47 @@ prevents runaway loops on a model that stays in tool-calling forever.
273273
See [`09 - Tool use`](../examples/09-tool-use.md) for the runnable
274274
shape.
275275

276+
### Controlling tool-call behavior with `tool_choice`
277+
278+
By default the model decides whether and which tools to call.
279+
`tool_choice` constrains that decision per call. Four modes:
280+
281+
- `"auto"` — the model decides. Equivalent to omitting the parameter
282+
when `tools` is non-empty.
283+
- `"required"` — the model MUST call at least one tool. Useful for
284+
routing nodes that branch on tool selection.
285+
- `"none"` — the model MUST NOT call tools, even if `tools` is
286+
supplied. Useful for guarded LLM calls or for explicitly disabling
287+
tool-calling without rebuilding a tools-less request.
288+
- `ForceTool(name=...)` — the model MUST call the named tool exactly.
289+
290+
Pre-send validation catches the three failure modes (`required` with
291+
empty tools, `ForceTool` with empty tools, `ForceTool.name` not in
292+
the supplied list) and raises `ProviderInvalidRequest` before the
293+
HTTP request is sent.
294+
295+
```python
296+
from openarmature.llm import ForceTool
297+
298+
# Routing node: model MUST pick one of the supplied tools.
299+
response = await provider.complete(
300+
messages, tools=[search, summarize], tool_choice="required"
301+
)
302+
303+
# Forced specific tool: useful when the pipeline knows which tool
304+
# the model should call next (e.g., a `dispatch_search` node).
305+
response = await provider.complete(
306+
messages, tools=[search, summarize], tool_choice=ForceTool(name="search")
307+
)
308+
```
309+
310+
Not all providers honor `tool_choice` — confirm with your provider's
311+
documentation. The `OpenAIProvider` maps the spec shape onto OpenAI's
312+
wire shape per the §8.1.1 mapping table. Whether the model actually
313+
honored the constraint is observable from the returned
314+
`finish_reason` and `tool_calls` fields; the framework does NOT
315+
re-validate the response against the constraint.
316+
276317
## Content blocks (multimodal user messages)
277318

278319
User messages carry content in one of two shapes: a plain text string,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Repository = "https://github.com/LunarCommand/openarmature-python"
4848
Specification = "https://github.com/LunarCommand/openarmature-spec"
4949

5050
[tool.openarmature]
51-
spec_version = "0.19.0"
51+
spec_version = "0.20.1"
5252

5353
[dependency-groups]
5454
dev = [

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""OpenArmature: workflow framework for LLM pipelines and tool-calling agents."""
22

33
__version__ = "0.8.0"
4-
__spec_version__ = "0.19.0"
4+
__spec_version__ = "0.20.1"

src/openarmature/llm/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
from .messages import (
4848
AssistantMessage,
4949
ContentBlock,
50+
ForceTool,
5051
ImageBlock,
5152
ImageSource,
5253
ImageSourceInline,
@@ -56,6 +57,7 @@
5657
TextBlock,
5758
Tool,
5859
ToolCall,
60+
ToolChoice,
5961
ToolMessage,
6062
UserMessage,
6163
)
@@ -64,6 +66,7 @@
6466
strict_mode_supported,
6567
validate_message_list,
6668
validate_response_schema,
69+
validate_tool_choice,
6770
validate_tools,
6871
)
6972
from .providers import OpenAIProvider, classify_http_error, parse_retry_after
@@ -83,6 +86,7 @@
8386
"AssistantMessage",
8487
"ContentBlock",
8588
"FinishReason",
89+
"ForceTool",
8690
"ImageBlock",
8791
"ImageSource",
8892
"ImageSourceInline",
@@ -107,6 +111,7 @@
107111
"TextBlock",
108112
"Tool",
109113
"ToolCall",
114+
"ToolChoice",
110115
"ToolMessage",
111116
"Usage",
112117
"UserMessage",
@@ -115,5 +120,6 @@
115120
"strict_mode_supported",
116121
"validate_message_list",
117122
"validate_response_schema",
123+
"validate_tool_choice",
118124
"validate_tools",
119125
]

src/openarmature/llm/messages.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,46 @@ class Tool(BaseModel):
6868
parameters: dict[str, Any]
6969

7070

71+
# Spec: realizes llm-provider §5 `tool_choice` discriminated-union
72+
# (proposal 0025). The string-literal modes (`"auto"`, `"required"`,
73+
# `"none"`) and the `ForceTool` record share the `ToolChoice` alias.
74+
# Implementations validate `tool_choice` against `tools` before send
75+
# (see ``validate_tool_choice`` in :mod:`provider`); violations raise
76+
# ``ProviderInvalidRequest`` per §7.
77+
class ForceTool(BaseModel):
78+
"""Force the model to call exactly the named tool.
79+
80+
Use the record form of the §5 `tool_choice` discriminated union
81+
when you need the model to call a specific tool by name. ``type``
82+
is the spec-level discriminator (``"tool"``); the wire mapping
83+
(§8.1.1) renames it to ``"function"`` for the OpenAI body. The
84+
``name`` MUST match a ``Tool.name`` in the supplied ``tools``
85+
list; ``validate_tool_choice`` enforces this at pre-send time and
86+
raises ``ProviderInvalidRequest`` on violation.
87+
"""
88+
89+
model_config = ConfigDict(extra="forbid", frozen=True)
90+
91+
# Frozen + extras-forbidden so a ``ForceTool`` instance is safely
92+
# hashable and structurally pinned. The ``Literal["tool"]`` default
93+
# makes ``ForceTool(name="search")`` ergonomic at the call site
94+
# while preserving the spec-level discriminator on the type.
95+
type: Literal["tool"] = "tool"
96+
name: str
97+
98+
99+
# Per spec §5: `tool_choice` is one of:
100+
# - ``"auto"`` — the model decides.
101+
# - ``"required"`` — the model MUST call at least one tool.
102+
# - ``"none"`` — the model MUST NOT call tools.
103+
# - ``ForceTool(name=X)`` — the model MUST call the named tool.
104+
# A union of the three string literals plus the record form.
105+
# Callers pass ``tool_choice=None`` (the default) to omit the field
106+
# from the wire — the provider's own default applies, preserving
107+
# pre-0025 behavior.
108+
ToolChoice = Literal["auto", "required", "none"] | ForceTool
109+
110+
71111
# ---------------------------------------------------------------------------
72112
# Per-role message classes
73113
# ---------------------------------------------------------------------------
@@ -274,6 +314,7 @@ class ToolMessage(_MessageBase):
274314
__all__ = [
275315
"AssistantMessage",
276316
"ContentBlock",
317+
"ForceTool",
277318
"ImageBlock",
278319
"ImageSource",
279320
"ImageSourceInline",
@@ -283,6 +324,7 @@ class ToolMessage(_MessageBase):
283324
"TextBlock",
284325
"Tool",
285326
"ToolCall",
327+
"ToolChoice",
286328
"ToolMessage",
287329
"UserMessage",
288330
]

src/openarmature/llm/provider.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@
4848
from .errors import ProviderInvalidRequest
4949
from .messages import (
5050
AssistantMessage,
51+
ForceTool,
5152
Message,
5253
SystemMessage,
5354
Tool,
55+
ToolChoice,
5456
ToolMessage,
5557
UserMessage,
5658
)
@@ -75,6 +77,7 @@ async def complete(
7577
tools: Sequence[Tool] | None = None,
7678
config: RuntimeConfig | None = None,
7779
response_schema: dict[str, Any] | type[BaseModel] | None = None,
80+
tool_choice: ToolChoice | None = None,
7881
) -> Response:
7982
"""Perform a single completion call.
8083
@@ -93,6 +96,12 @@ async def complete(
9396
supplied, the implementation constrains the model's
9497
output to the schema and populates ``Response.parsed``
9598
with the validated value.
99+
tool_choice: Optional tool-choice constraint (spec §5). One
100+
of ``"auto"``, ``"required"``, ``"none"``, or a
101+
:class:`ForceTool` record. When ``None`` (the default)
102+
the wire ``tool_choice`` field is omitted and the
103+
provider's own default applies. Pre-send validation
104+
routes through ``provider_invalid_request``.
96105
"""
97106
...
98107

@@ -174,6 +183,89 @@ def validate_tools(tools: Sequence[Tool] | None) -> None:
174183
seen.add(t.name)
175184

176185

186+
# The string literals allowed under the §5 `tool_choice` shape.
187+
# Pyright catches non-literal strings at type-check time via the
188+
# ``ToolChoice = Literal[...] | ForceTool`` alias, but Python does
189+
# not enforce Literal at runtime — untyped callers (tests, dynamic
190+
# harnesses, ad-hoc scripts) can pass an arbitrary string. The
191+
# runtime check below is the API-boundary defense against that.
192+
_ALLOWED_TOOL_CHOICE_MODES: frozenset[str] = frozenset({"auto", "required", "none"})
193+
194+
195+
# Spec: realizes llm-provider §5 `tool_choice` pre-send validation
196+
# rules (proposal 0025). The three failure modes route through the
197+
# existing §7 ``provider_invalid_request`` category; no new error
198+
# categories per the spec's "no new category" framing. Validation
199+
# fires BEFORE any HTTP request is sent (fixture 031's mock_provider
200+
# returns an empty response list on these cases to fail the test
201+
# if a request escapes the validation gate).
202+
def validate_tool_choice(
203+
tool_choice: ToolChoice | None,
204+
tools: Sequence[Tool] | None,
205+
) -> None:
206+
"""Validate ``tool_choice`` against ``tools`` per spec §5.
207+
208+
Raises :class:`ProviderInvalidRequest` (the §7
209+
``provider_invalid_request`` category) on:
210+
211+
- ``tool_choice`` supplied as a string that is not one of
212+
``"auto"`` / ``"required"`` / ``"none"`` (runtime defense
213+
against untyped callers; the Literal alias catches well-typed
214+
ones at type-check time).
215+
- ``tool_choice="required"`` supplied with empty / absent
216+
``tools``.
217+
- ``tool_choice=ForceTool(name=X)`` supplied with empty / absent
218+
``tools``.
219+
- ``tool_choice=ForceTool(name=X)`` supplied with ``X`` not in the
220+
supplied tools list.
221+
222+
No-op when ``tool_choice`` is ``None`` (the default — preserves
223+
pre-0025 behavior; the wire field is omitted and the provider's
224+
own default applies). ``tool_choice="auto"`` and
225+
``tool_choice="none"`` have no ``tools``-related preconditions.
226+
"""
227+
if tool_choice is None:
228+
return
229+
# Two-layer type defense at the API boundary. Pyright catches the
230+
# well-formed path via the ``ToolChoice = Literal[...] | ForceTool``
231+
# alias; the runtime checks below cover untyped callers that bypass
232+
# type-check (tests, dynamic harnesses, ad-hoc scripts). A caller
233+
# hand-building a dict like ``{"type": "tool", "name": X}`` thinking
234+
# that's the API would otherwise fall through to the wire and yield
235+
# a hard-to-debug provider-side 4xx — the spec→wire rename only
236+
# runs on actual ``ForceTool`` instances.
237+
if isinstance(tool_choice, str):
238+
if tool_choice not in _ALLOWED_TOOL_CHOICE_MODES:
239+
raise ProviderInvalidRequest(
240+
f'tool_choice {tool_choice!r} is not one of "auto" / "required" / "none"'
241+
)
242+
# Pyright narrows ``tool_choice`` to ``ForceTool`` here based on the
243+
# type alias, so the isinstance check looks unnecessary to it. But
244+
# the entire purpose of this validator is to defend against untyped
245+
# callers passing arbitrary values at runtime — the static narrowing
246+
# doesn't hold for them. The pyright suppression is load-bearing.
247+
elif not isinstance(tool_choice, ForceTool): # pyright: ignore[reportUnnecessaryIsInstance]
248+
raise ProviderInvalidRequest(
249+
f'tool_choice must be one of "auto" / "required" / "none" or a ForceTool instance, '
250+
f"got {type(tool_choice).__name__}"
251+
)
252+
has_tools = bool(tools)
253+
if tool_choice == "required" and not has_tools:
254+
raise ProviderInvalidRequest('tool_choice="required" requires non-empty tools')
255+
if isinstance(tool_choice, ForceTool):
256+
if not has_tools:
257+
raise ProviderInvalidRequest(
258+
f"tool_choice ForceTool(name={tool_choice.name!r}) requires non-empty tools"
259+
)
260+
# ``tools`` is non-empty here per the preceding guard. The list
261+
# is also guaranteed non-None inside this branch.
262+
names = {t.name for t in tools or ()}
263+
if tool_choice.name not in names:
264+
raise ProviderInvalidRequest(
265+
f"tool_choice name {tool_choice.name!r} not in tools (declared: {sorted(names)})"
266+
)
267+
268+
177269
# ---------------------------------------------------------------------------
178270
# Schema helpers — used by structured-output Provider implementations
179271
# ---------------------------------------------------------------------------
@@ -485,5 +577,6 @@ def _resolve_ref(ref: str, root: dict[str, Any]) -> Any:
485577
"strict_mode_supported",
486578
"validate_message_list",
487579
"validate_response_schema",
580+
"validate_tool_choice",
488581
"validate_tools",
489582
]

0 commit comments

Comments
 (0)