Skip to content

Commit dfbcb02

Browse files
llm: spec v0.8 llm-provider (proposal 0006) — phase 4 (#17)
* llm: spec v0.8 llm-provider (proposal 0006) — phase 4 Land the stateless LLM provider abstraction. Adds `openarmature.llm` with the typed Message/Tool/Response surface, the seven §7 error categories, list-level boundary validation, and an OpenAI-compatible HTTPX provider that talks to the OpenAI hosted API plus local servers (vLLM, LM Studio, llama.cpp). The canonical TRANSIENT_CATEGORIES set moves into llm.errors so retry middleware imports it from a single source of truth — Phase 2 hardcoded the same set ahead of llm-provider landing. Test coverage: 8 spec conformance fixtures (drives the real provider through httpx.MockTransport so wire-mapping is exercised end-to-end) plus 46 unit tests covering per-role validation, tool-call id verbatim preservation, error category contracts, and the HTTP-status-to-category mapping table. * llm: enforce Usage non-negative constraint per spec §6 Spec §6 documents `Usage` token counts as non-negative integers, but the fields were plain `int | None` and silently accepted negatives. Add `Field(ge=0)` so a malformed wire response surfaces as `provider_invalid_response` (the `_parse_response` Usage build wraps the resulting `ValidationError`, matching the existing `AssistantMessage` pattern). * llm: document why validate_message_list skips role alternation Future readers (and users hitting vLLM-Mistral 400s) need a pointer to where the role-alternation constraint lives. Strict templates enforce user→assistant alternation server-side; OpenAI and Anthropic don't. Pre-rejecting at the client boundary would over-restrict the permissive case. * llm: group providers under llm/providers/ subpackage Mirrors the openarmature.graph.middleware shape so the package layout signals where future providers (Anthropic, Bedrock, gateway-shaped) will live. Public surface is unchanged — `from openarmature.llm import OpenAIProvider` still works via the package-root re-export. * llm: clarify transient-set count and legacy finish_reason mapping Two doc-only cleanups from PR #17 review: - errors.py: TRANSIENT_CATEGORIES has three entries, not four — comment was off by one. - providers/openai.py: tighten the comment on the `finish_reason: "function_call"` → `"tool_calls"` rename so it's clear this is a value rename (per spec §8.2 / conformance fixture 005), not a message-field transformation. We do not translate the deprecated single `message.function_call` shape — none of the backends we currently target emit it.
1 parent 7e597f7 commit dfbcb02

12 files changed

Lines changed: 2050 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ license = "Apache-2.0"
1313
license-files = ["LICENSE"]
1414
dependencies = [
1515
"pydantic>=2.7",
16+
"httpx>=0.27",
1617
]
1718

1819
[project.urls]

src/openarmature/graph/middleware/retry.py

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,13 @@
2020
from collections.abc import Awaitable, Callable, Mapping
2121
from typing import Any
2222

23+
from openarmature.llm.errors import TRANSIENT_CATEGORIES
24+
2325
from ._core import NextCall
2426

25-
# Default transient set per pipeline-utilities §6.1: matches against an
26-
# exception's ``category`` attribute (or the cause's, if the exception is
27-
# a NodeException wrapper). The canonical strings are spec-defined and
28-
# loose-coupled to llm-provider §7 — implementers don't have to import
29-
# the llm-provider module to wire up retry.
30-
TRANSIENT_CATEGORIES: frozenset[str] = frozenset(
31-
{
32-
"provider_rate_limit",
33-
"provider_unavailable",
34-
"provider_model_not_loaded",
35-
}
36-
)
27+
# ``TRANSIENT_CATEGORIES`` is re-exported via this module's __all__
28+
# below. Canonical source of truth lives in ``openarmature.llm.errors``
29+
# (per llm-provider §7) — the retry middleware is just a consumer.
3730

3831

3932
def default_classifier(exc: Exception, _state: Any) -> bool:

src/openarmature/llm/__init__.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""openarmature.llm — llm-provider capability per spec proposal 0006.
2+
3+
Public surface: typed ``Message`` / ``Tool`` / ``Response``, the
4+
``Provider`` Protocol, the canonical error categories, and an
5+
OpenAI-compatible provider. Users write::
6+
7+
from openarmature.llm import (
8+
AssistantMessage,
9+
OpenAIProvider,
10+
Provider,
11+
SystemMessage,
12+
Tool,
13+
ToolCall,
14+
UserMessage,
15+
)
16+
17+
All seven §7 error categories and the canonical ``TRANSIENT_CATEGORIES``
18+
frozenset are also re-exported here so callers writing custom retry
19+
classifiers don't have to reach into ``openarmature.llm.errors``.
20+
"""
21+
22+
from .errors import (
23+
PROVIDER_AUTHENTICATION,
24+
PROVIDER_INVALID_MODEL,
25+
PROVIDER_INVALID_REQUEST,
26+
PROVIDER_INVALID_RESPONSE,
27+
PROVIDER_MODEL_NOT_LOADED,
28+
PROVIDER_RATE_LIMIT,
29+
PROVIDER_UNAVAILABLE,
30+
TRANSIENT_CATEGORIES,
31+
LlmProviderError,
32+
ProviderAuthentication,
33+
ProviderInvalidModel,
34+
ProviderInvalidRequest,
35+
ProviderInvalidResponse,
36+
ProviderModelNotLoaded,
37+
ProviderRateLimit,
38+
ProviderUnavailable,
39+
)
40+
from .messages import (
41+
AssistantMessage,
42+
Message,
43+
SystemMessage,
44+
Tool,
45+
ToolCall,
46+
ToolMessage,
47+
UserMessage,
48+
)
49+
from .provider import Provider, validate_message_list, validate_tools
50+
from .providers import OpenAIProvider
51+
from .response import FinishReason, Response, RuntimeConfig, Usage
52+
53+
__all__ = [
54+
"PROVIDER_AUTHENTICATION",
55+
"PROVIDER_INVALID_MODEL",
56+
"PROVIDER_INVALID_REQUEST",
57+
"PROVIDER_INVALID_RESPONSE",
58+
"PROVIDER_MODEL_NOT_LOADED",
59+
"PROVIDER_RATE_LIMIT",
60+
"PROVIDER_UNAVAILABLE",
61+
"TRANSIENT_CATEGORIES",
62+
"AssistantMessage",
63+
"FinishReason",
64+
"LlmProviderError",
65+
"Message",
66+
"OpenAIProvider",
67+
"Provider",
68+
"ProviderAuthentication",
69+
"ProviderInvalidModel",
70+
"ProviderInvalidRequest",
71+
"ProviderInvalidResponse",
72+
"ProviderModelNotLoaded",
73+
"ProviderRateLimit",
74+
"ProviderUnavailable",
75+
"Response",
76+
"RuntimeConfig",
77+
"SystemMessage",
78+
"Tool",
79+
"ToolCall",
80+
"ToolMessage",
81+
"Usage",
82+
"UserMessage",
83+
"validate_message_list",
84+
"validate_tools",
85+
]

src/openarmature/llm/errors.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Errors raised by an llm-provider implementation.
2+
3+
Per spec llm-provider §7: a provider call (``ready()`` or
4+
``complete()``) MAY raise one of seven canonical category errors.
5+
Each error class carries a ``category`` class attribute matching the
6+
canonical string identifier so callers can dispatch on the category
7+
without matching exception types directly.
8+
9+
This module is also the single source of truth for the canonical
10+
category strings — :data:`TRANSIENT_CATEGORIES` lives here, and
11+
``openarmature.graph.middleware.retry``'s default classifier imports
12+
it. Phase 2's retry middleware deliberately hardcoded the set to
13+
avoid a circular dependency before llm-provider was implemented;
14+
now that this module exists, the strings have a real home.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from typing import Any
20+
21+
# ---------------------------------------------------------------------------
22+
# Canonical category strings (spec §7)
23+
# ---------------------------------------------------------------------------
24+
25+
PROVIDER_AUTHENTICATION = "provider_authentication"
26+
PROVIDER_UNAVAILABLE = "provider_unavailable"
27+
PROVIDER_INVALID_MODEL = "provider_invalid_model"
28+
PROVIDER_MODEL_NOT_LOADED = "provider_model_not_loaded"
29+
PROVIDER_RATE_LIMIT = "provider_rate_limit"
30+
PROVIDER_INVALID_RESPONSE = "provider_invalid_response"
31+
PROVIDER_INVALID_REQUEST = "provider_invalid_request"
32+
33+
34+
# Per spec §7 "Retry classification": these three categories are
35+
# *transient* — a retry MAY succeed. The other four
36+
# (`provider_authentication`, `provider_invalid_model`,
37+
# `provider_invalid_request`, `provider_invalid_response`) are
38+
# non-transient and MUST NOT be retried by the default classifier.
39+
#
40+
# Note: ``finish_reason: "error"`` is also transient per spec §7, but
41+
# that's a Response-level signal rather than an exception category, so
42+
# it isn't part of this set — the default retry middleware operates on
43+
# raised exceptions.
44+
TRANSIENT_CATEGORIES: frozenset[str] = frozenset(
45+
{
46+
PROVIDER_RATE_LIMIT,
47+
PROVIDER_UNAVAILABLE,
48+
PROVIDER_MODEL_NOT_LOADED,
49+
}
50+
)
51+
52+
53+
# ---------------------------------------------------------------------------
54+
# Exception classes
55+
# ---------------------------------------------------------------------------
56+
57+
58+
class LlmProviderError(Exception):
59+
"""Base for all llm-provider errors. Each subclass carries a
60+
``category`` class attribute matching one of the spec §7 strings.
61+
62+
Provider-originated errors SHOULD preserve the underlying provider
63+
exception as ``__cause__`` so callers can reach the wire-level
64+
detail when needed.
65+
"""
66+
67+
category: str
68+
69+
70+
class ProviderAuthentication(LlmProviderError):
71+
"""Auth failed — invalid key, expired token, missing credentials."""
72+
73+
category = PROVIDER_AUTHENTICATION
74+
75+
76+
class ProviderUnavailable(LlmProviderError):
77+
"""Provider is unreachable — network failure, 5xx error, DNS, timeout."""
78+
79+
category = PROVIDER_UNAVAILABLE
80+
81+
82+
class ProviderInvalidModel(LlmProviderError):
83+
"""The bound model does not exist on this provider. Terminal —
84+
retry will not succeed without changing the bound model."""
85+
86+
category = PROVIDER_INVALID_MODEL
87+
88+
89+
class ProviderModelNotLoaded(LlmProviderError):
90+
"""The bound model is known to the provider but is not currently
91+
serving (e.g., a local vLLM/LM Studio/llama.cpp server has the
92+
model configured but not loaded). Distinct from
93+
``provider_invalid_model`` because retry MAY succeed once loading
94+
completes."""
95+
96+
category = PROVIDER_MODEL_NOT_LOADED
97+
98+
99+
class ProviderRateLimit(LlmProviderError):
100+
"""Provider returned a rate-limit response (HTTP 429 or equivalent).
101+
102+
When the provider supplies a ``Retry-After`` header (or its
103+
equivalent), the parsed seconds-to-wait surfaces on
104+
:attr:`retry_after`. ``None`` if the provider didn't include one.
105+
"""
106+
107+
category = PROVIDER_RATE_LIMIT
108+
retry_after: float | None
109+
110+
def __init__(self, *args: Any, retry_after: float | None = None) -> None:
111+
super().__init__(*args)
112+
self.retry_after = retry_after
113+
114+
115+
class ProviderInvalidResponse(LlmProviderError):
116+
"""Provider returned a malformed response that cannot be parsed
117+
into the §6 shape (missing required fields, invalid tool_calls
118+
structure, invalid JSON)."""
119+
120+
category = PROVIDER_INVALID_RESPONSE
121+
122+
123+
class ProviderInvalidRequest(LlmProviderError):
124+
"""The request was malformed before sending (per-role message
125+
constraints violated, ``tool_call_id`` does not match an earlier
126+
assistant tool call, duplicate tool names, etc.). Raised by the
127+
implementation's pre-send validation, not by the provider."""
128+
129+
category = PROVIDER_INVALID_REQUEST
130+
131+
132+
__all__ = [
133+
"PROVIDER_AUTHENTICATION",
134+
"PROVIDER_INVALID_MODEL",
135+
"PROVIDER_INVALID_REQUEST",
136+
"PROVIDER_INVALID_RESPONSE",
137+
"PROVIDER_MODEL_NOT_LOADED",
138+
"PROVIDER_RATE_LIMIT",
139+
"PROVIDER_UNAVAILABLE",
140+
"TRANSIENT_CATEGORIES",
141+
"LlmProviderError",
142+
"ProviderAuthentication",
143+
"ProviderInvalidModel",
144+
"ProviderInvalidRequest",
145+
"ProviderInvalidResponse",
146+
"ProviderModelNotLoaded",
147+
"ProviderRateLimit",
148+
"ProviderUnavailable",
149+
]

0 commit comments

Comments
 (0)