Implement proposal 0025: tool_choice + absorb 0026 §8.X template#70
Merged
Conversation
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.
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.
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).
There was a problem hiding this comment.
Pull request overview
Implements spec proposal 0025 by adding tool_choice support to the Provider.complete() API, validating the constraint pre-send, and mapping it onto the OpenAI Chat Completions wire format; also updates the pinned spec version to v0.20.1 (absorbing proposal 0026’s §8.X template text).
Changes:
- Extend
Provider.complete()withtool_choice: ToolChoice | Noneplus shared validation (validate_tool_choice) and public types (ForceTool,ToolChoice). - Add OpenAI wire mapping for
tool_choice(omit whenNone; mapForceToolto{type:"function", function:{name:...}}). - Add unit + conformance harness coverage and bump spec version pins/docs/changelog.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_tool_choice.py | New unit tests for ForceTool, validate_tool_choice, and OpenAI wire mapping behavior. |
| tests/test_smoke.py | Updates asserted pinned spec version to 0.20.1. |
| tests/conformance/test_llm_provider.py | Extends fixture parsing and provider calls to support tool_choice. |
| tests/conformance/harness/wire.py | Adds assert_tool_choice_absent wire-body check helper. |
| tests/conformance/harness/init.py | Exports assert_tool_choice_absent. |
| src/openarmature/llm/providers/openai.py | Adds tool_choice parameter, validates it, and maps it to OpenAI request body. |
| src/openarmature/llm/provider.py | Extends Provider Protocol signature and introduces validate_tool_choice. |
| src/openarmature/llm/messages.py | Introduces ForceTool model and ToolChoice type alias; exports them. |
| src/openarmature/llm/init.py | Re-exports ForceTool, ToolChoice, and validate_tool_choice. |
| src/openarmature/init.py | Bumps __spec_version__ constant to 0.20.1. |
| pyproject.toml | Bumps [tool.openarmature].spec_version to 0.20.1. |
| docs/concepts/llms.md | Documents tool_choice usage and semantics. |
| CHANGELOG.md | Documents new API, behavior, and spec pin bump. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements spec proposal 0025 (
tool_choiceon the Provider interface) and absorbs proposal 0026 (§8.X wire-format mapping subsection template — purely textual) inopenarmature-python. Bumps the spec submodule pin from v0.19.0 to v0.20.1.Provider.complete()gains an optionaltool_choice: ToolChoice | None = Noneparameter — one of"auto","required","none", or aForceTool(name=...)record. Pre-send validation covers the three §5 rules (required/empty-tools,ForceTool/empty-tools,ForceTool.namenot in tools) and raisesProviderInvalidRequest(§7's existing category; no new error category). The OpenAI wire mapping in_build_request_bodytranslates the spec shape onto OpenAI's body per the §8.1.1 table: string literals pass through verbatim;ForceToolrenamestypeto"function"and nests the name under afunctionsub-object. Whentool_choiceisNone(the default) the wire field is omitted entirely — pre-0025 callers see no wire-shape change.ForceToolis a frozen Pydantic model withtype: Literal["tool"] = "tool"matching the spec's discriminator at the type system level; the wire-side rename (tool→function) happens inside the provider, not at the type.Spec alignment
OpenAIProvideris the template's reference shape — the proposal explicitly says "§8.1 already follows the recommended template by definition." Pin bump only.What's new
Public API:
openarmature.llm.ForceTool— frozen Pydantic model (type: Literal["tool"]+name: str).openarmature.llm.ToolChoice— type alias:Literal["auto", "required", "none"] | ForceTool.openarmature.llm.validate_tool_choice— standalone validator for the three §5 rules; useful for third-partyProviderimplementations.Provider.complete(..., tool_choice: ToolChoice | None = None) -> Response— extended signature.Behavior changes:
OpenAIProvider.complete()accepts and forwardstool_choice. The wire body emits the spec's discriminated-union value per §8.1.1.validate_tool_choiceruns aftervalidate_toolsso name-membership checks see a structurally valid tools list.Providerimplementations that don't add the parameter still structurally satisfy the Protocol; their wire path silently dropstool_choice(per spec, whether a provider honorstool_choiceis a per-provider concern).Test plan
uv run pytest tests/— 795 pass, 74 skipped, 0 failures.uv run pytest tests/unit/test_tool_choice.py -v— 15 new unit tests covering validation rules +ForceToolshape + wire mapping.uv run pytest tests/conformance/test_llm_provider.py -k tool-choice— 3 new conformance fixtures (029, 030, 031) all pass.src/openarmature/llm/messages.pyForceTool+ToolChoicedefinitions.src/openarmature/llm/provider.pyvalidate_tool_choiceand the extended Protocol signature.src/openarmature/llm/providers/openai.pycomplete()+_build_request_bodywire mapping.mkdocs serveand review the new "Controlling tool-call behavior withtool_choice" subsection underdocs/concepts/llms.md.Out of scope (deferred)
{type: "tool", names: [X, Y]}) — spec alternatives §; no current providers support it on the wire.force_tool(name)helper instead of the discriminated union — spec alternatives §; explicit-API-over-sugar.