From c12276ed949aa059a7d2bd3b317ee508c5d69e71 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 06:56:43 +0000 Subject: [PATCH 1/8] Add pluggable LLM API provider hook with tool calling Introduce a vendor-neutral BaseLLMProvider interface (osprey.worker.lib.llm) designed for tool calling from day one: tool definitions in, tool-call requests out, tool results back in. Add a register_llm_provider pluggy hookspec (firstresult=True) and a bootstrap_llm_provider() helper, mirroring the existing single-provider hooks (register_input_stream, register_execution_result_store). Add an example provider in example_plugins that talks to the Anthropic Messages API directly, demonstrating the request/response and tool_use translation. The anthropic SDK is imported lazily and is not declared as a workspace dependency (it conflicts with the pinned typing-extensions), so it stays optional and CI needs no SDK, API key, or network. Includes unit tests (dataclass invariants + a full tool-call cycle against a fake Anthropic client) and docs for the new hook in docs/DEVELOPMENT.md. --- docs/DEVELOPMENT.md | 46 ++++ example_plugins/pyproject.toml | 9 + example_plugins/src/llm/__init__.py | 5 + example_plugins/src/llm/anthropic_provider.py | 215 ++++++++++++++++ example_plugins/src/llm/tests/__init__.py | 0 .../src/llm/tests/test_anthropic_provider.py | 240 ++++++++++++++++++ example_plugins/src/register_plugins.py | 13 + .../worker/adaptor/hookspecs/osprey_hooks.py | 12 + .../osprey/worker/adaptor/plugin_manager.py | 17 ++ .../src/osprey/worker/lib/llm/__init__.py | 30 +++ .../src/osprey/worker/lib/llm/base.py | 143 +++++++++++ .../osprey/worker/lib/llm/tests/__init__.py | 0 .../osprey/worker/lib/llm/tests/test_base.py | 103 ++++++++ pyproject.toml | 6 +- 14 files changed, 837 insertions(+), 2 deletions(-) create mode 100644 example_plugins/src/llm/__init__.py create mode 100644 example_plugins/src/llm/anthropic_provider.py create mode 100644 example_plugins/src/llm/tests/__init__.py create mode 100644 example_plugins/src/llm/tests/test_anthropic_provider.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/__init__.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/base.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/tests/__init__.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/tests/test_base.py diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 514e8bb0..94c3bb77 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -125,6 +125,52 @@ def register_ast_validators() -> None: # Register AST validators ``` +### Available hooks + +Implement any subset of these in your plugin's `register_plugins.py`: + +| Hook | Returns | Notes | +| --- | --- | --- | +| `register_udfs` | `Sequence[Type[UDFBase]]` | Custom user-defined functions. | +| `register_output_sinks` | `Sequence[BaseOutputSink]` | Where execution results go. | +| `register_ast_validators` | `Sequence[Type[BaseValidator]]` | Extra SML validators. | +| `register_action_proto_deserializer` | `ActionProtoDeserializer \| None` | Custom action proto → JSON. | +| `register_input_stream` | `BaseInputStream` | Single-provider (`firstresult`). | +| `register_execution_result_store` | `ExecutionResultStore` | Single-provider (`firstresult`). | +| `register_labels_service_or_provider` | `LabelsServiceBase \| LabelsProvider` | Single-provider (`firstresult`). | +| `register_llm_provider` | `BaseLLMProvider` | Single-provider (`firstresult`). LLM API access for AI-assisted features. | + +### LLM provider hook + +`register_llm_provider` lets a plugin supply the LLM API client used by AI-assisted +features (e.g. natural-language query building). The interface lives in +`osprey.worker.lib.llm` and is vendor-neutral and **tool-calling aware**: you pass +`ToolDefinition`s in, the model may return `ToolCall`s, and you feed `ToolResult`s +back on the next `chat()` call. + +```python +from osprey.worker.lib.config import Config +from osprey.worker.lib.llm.base import BaseLLMProvider + +@hookimpl_osprey +def register_llm_provider(config: Config) -> BaseLLMProvider: + return MyLLMProvider(config) +``` + +Only one provider may be registered (`firstresult=True`). Retrieve it with +`bootstrap_llm_provider(config)` from `osprey.worker.adaptor.plugin_manager`, which +returns `None` when no plugin registers one — so callers should null-check. + +A direct Anthropic implementation is provided as a reference in +`example_plugins/src/llm/anthropic_provider.py`, including the request/response and +`tool_use` translation. It imports the `anthropic` SDK lazily; the SDK is **not** +declared as a workspace dependency (it conflicts with the pinned +`typing-extensions`), so install it manually to actually run the provider: + +```bash +uv pip install anthropic +``` + ## Rules Rules are written in SML, some examples are provided in `example_rules/` with YAML config, the rules are mounted to the worker processes when the containers start via environment variables. ex: diff --git a/example_plugins/pyproject.toml b/example_plugins/pyproject.toml index 0eaed7dc..335cf25f 100644 --- a/example_plugins/pyproject.toml +++ b/example_plugins/pyproject.toml @@ -7,6 +7,15 @@ dependencies = [ "pluggy==1.5.0" ] +# NOTE: The example Anthropic LLM provider (llm/anthropic_provider.py) needs the +# `anthropic` SDK, but it is intentionally NOT declared here. The workspace pins +# `typing-extensions==4.6.3`, while every modern `anthropic` requires a newer +# typing-extensions, so declaring it (even as an optional extra) makes the uv +# workspace lock unsatisfiable. The provider imports `anthropic` lazily and raises +# a clear error if it is missing; install it manually to use the provider, e.g.: +# uv pip install anthropic +# (You may also need to relax the typing-extensions pin in the root workspace.) + [tool.setuptools] package-dir = {"" = "src"} diff --git a/example_plugins/src/llm/__init__.py b/example_plugins/src/llm/__init__.py new file mode 100644 index 00000000..6cc3d0fb --- /dev/null +++ b/example_plugins/src/llm/__init__.py @@ -0,0 +1,5 @@ +"""Example LLM provider plugins for Osprey. + +See :mod:`llm.anthropic_provider` for a direct Anthropic Messages API implementation +of :class:`osprey.worker.lib.llm.base.BaseLLMProvider`. +""" diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py new file mode 100644 index 00000000..f794541e --- /dev/null +++ b/example_plugins/src/llm/anthropic_provider.py @@ -0,0 +1,215 @@ +"""Example LLM provider backed directly by the Anthropic Messages API. + +This demonstrates implementing :class:`osprey.worker.lib.llm.base.BaseLLMProvider`, +including tool calling: it translates the vendor-neutral ``LLMMessage`` / +``ToolDefinition`` types into Anthropic's request format, and maps the response +(including ``tool_use`` blocks) back into ``LLMResponse`` / ``ToolCall``. + +The ``anthropic`` SDK is an optional dependency (``example_plugins[llm]``). It is +imported lazily so the base example package, and Osprey's CI, do not require the +SDK, an API key, or network access unless this provider is actually used. + +Configuration (via Osprey ``Config`` or environment): + +- API key: ``LLM_ANTHROPIC_API_KEY`` config key, else the ``ANTHROPIC_API_KEY`` + environment variable (read by the SDK itself if neither is set explicitly). +- Default model: ``LLM_ANTHROPIC_MODEL`` config key + (default: ``claude-3-5-sonnet-latest``). +- Default max tokens: ``LLM_ANTHROPIC_MAX_TOKENS`` config key (default: ``1024``). +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence + +from osprey.worker.lib.config import Config +from osprey.worker.lib.llm.base import ( + BaseLLMProvider, + LLMMessage, + LLMResponse, + LLMUsage, + ToolCall, + ToolDefinition, +) + +if TYPE_CHECKING: + from anthropic import Anthropic + +DEFAULT_MODEL = 'claude-3-5-sonnet-latest' +DEFAULT_MAX_TOKENS = 1024 + + +class AnthropicLLMProvider(BaseLLMProvider): + """A :class:`BaseLLMProvider` that calls the Anthropic Messages API directly.""" + + def __init__(self, config: Config, client: Optional['Anthropic'] = None) -> None: + self._config = config + self._default_model = config.get_str('LLM_ANTHROPIC_MODEL', DEFAULT_MODEL) + self._default_max_tokens = config.get_int('LLM_ANTHROPIC_MAX_TOKENS', DEFAULT_MAX_TOKENS) + # Allow injecting a client (used in tests); otherwise build lazily on first use. + self._client = client + + def _get_client(self) -> 'Anthropic': + if self._client is not None: + return self._client + + try: + import anthropic + except ImportError as exc: # pragma: no cover - exercised only without the optional dep + raise RuntimeError( + "The 'anthropic' package is required to use AnthropicLLMProvider. " + "Install it with the optional extra, e.g. `uv pip install 'example_plugins[llm]'`." + ) from exc + + api_key = self._config.get_optional_str('LLM_ANTHROPIC_API_KEY') or os.environ.get('ANTHROPIC_API_KEY') + # If api_key is None the SDK still reads ANTHROPIC_API_KEY from the environment itself. + self._client = anthropic.Anthropic(api_key=api_key) if api_key else anthropic.Anthropic() + return self._client + + def chat( + self, + *, + messages: Sequence[LLMMessage], + system: Optional[str] = None, + tools: Optional[Sequence[ToolDefinition]] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **params: Any, + ) -> LLMResponse: + request: Dict[str, Any] = { + 'model': model or self._default_model, + 'max_tokens': max_tokens or self._default_max_tokens, + 'messages': self._to_anthropic_messages(messages), + } + + system_text = self._collect_system_text(system, messages) + if system_text is not None: + request['system'] = system_text + + if tools: + request['tools'] = [self._to_anthropic_tool(tool) for tool in tools] + + if temperature is not None: + request['temperature'] = temperature + + # Provider-specific passthrough (e.g. top_p, stop_sequences, tool_choice). + request.update(params) + + response = self._get_client().messages.create(**request) + return self._from_anthropic_response(response) + + # --- request translation ------------------------------------------------ + + @staticmethod + def _collect_system_text(system: Optional[str], messages: Sequence[LLMMessage]) -> Optional[str]: + parts: List[str] = [] + if system: + parts.append(system) + # Anthropic carries the system prompt as a top-level field, not a message, + # so fold any role='system' messages into it. + for message in messages: + if message.role == 'system' and message.content: + parts.append(message.content) + if not parts: + return None + return '\n\n'.join(parts) + + @staticmethod + def _to_anthropic_tool(tool: ToolDefinition) -> Dict[str, Any]: + return { + 'name': tool.name, + 'description': tool.description, + 'input_schema': tool.input_schema, + } + + @classmethod + def _to_anthropic_messages(cls, messages: Sequence[LLMMessage]) -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for message in messages: + # System messages are handled separately via the top-level `system` field. + if message.role == 'system': + continue + + blocks = cls._message_content_blocks(message) + if not blocks: + continue + + if message.cache_control is not None: + cache_control: Dict[str, Any] = {'type': 'ephemeral'} + if message.cache_control.ttl is not None: + cache_control['ttl'] = message.cache_control.ttl + blocks[-1]['cache_control'] = cache_control + + # Tool results are surfaced to Anthropic as a user-role message. + role = 'user' if message.role == 'tool' else message.role + out.append({'role': role, 'content': blocks}) + return out + + @staticmethod + def _message_content_blocks(message: LLMMessage) -> List[Dict[str, Any]]: + blocks: List[Dict[str, Any]] = [] + + if message.content: + blocks.append({'type': 'text', 'text': message.content}) + + for tool_call in message.tool_calls: + blocks.append( + { + 'type': 'tool_use', + 'id': tool_call.id, + 'name': tool_call.name, + 'input': tool_call.arguments, + } + ) + + for tool_result in message.tool_results: + blocks.append( + { + 'type': 'tool_result', + 'tool_use_id': tool_result.tool_call_id, + 'content': tool_result.content, + 'is_error': tool_result.is_error, + } + ) + + return blocks + + # --- response translation ------------------------------------------------ + + @staticmethod + def _from_anthropic_response(response: Any) -> LLMResponse: + text_parts: List[str] = [] + tool_calls: List[ToolCall] = [] + + for block in getattr(response, 'content', None) or []: + block_type = getattr(block, 'type', None) + if block_type == 'text': + text_parts.append(getattr(block, 'text', '') or '') + elif block_type == 'tool_use': + tool_calls.append( + ToolCall( + id=getattr(block, 'id', ''), + name=getattr(block, 'name', ''), + arguments=dict(getattr(block, 'input', {}) or {}), + ) + ) + + usage: Optional[LLMUsage] = None + raw_usage = getattr(response, 'usage', None) + if raw_usage is not None: + usage = LLMUsage( + input_tokens=getattr(raw_usage, 'input_tokens', 0) or 0, + output_tokens=getattr(raw_usage, 'output_tokens', 0) or 0, + cache_read_tokens=getattr(raw_usage, 'cache_read_input_tokens', 0) or 0, + cache_write_tokens=getattr(raw_usage, 'cache_creation_input_tokens', 0) or 0, + ) + + return LLMResponse( + text=''.join(text_parts), + tool_calls=tool_calls, + stop_reason=getattr(response, 'stop_reason', None), + usage=usage, + raw=response, + ) diff --git a/example_plugins/src/llm/tests/__init__.py b/example_plugins/src/llm/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/example_plugins/src/llm/tests/test_anthropic_provider.py b/example_plugins/src/llm/tests/test_anthropic_provider.py new file mode 100644 index 00000000..220eba17 --- /dev/null +++ b/example_plugins/src/llm/tests/test_anthropic_provider.py @@ -0,0 +1,240 @@ +"""Tests for the example Anthropic LLM provider. + +These use a fake Anthropic client (no network, no SDK, no API key) to verify the +vendor-neutral <-> Anthropic translation, including a full tool-call cycle. +""" + +from typing import Any, Dict, List + +from osprey.worker.lib.config import Config +from osprey.worker.lib.llm.base import ( + CacheControl, + LLMMessage, + ToolCall, + ToolDefinition, + ToolResult, +) + +from llm.anthropic_provider import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, AnthropicLLMProvider + + +class _Block: + """Mimics an Anthropic content block (text or tool_use).""" + + def __init__(self, **kwargs: Any) -> None: + self.__dict__.update(kwargs) + + +class _Usage: + def __init__(self, **kwargs: Any) -> None: + self.__dict__.update(kwargs) + + +class _Response: + def __init__(self, content: List[_Block], stop_reason: str, usage: _Usage) -> None: + self.content = content + self.stop_reason = stop_reason + self.usage = usage + + +class _FakeMessages: + def __init__(self, responses: List[_Response]) -> None: + self._responses = responses + self.calls: List[Dict[str, Any]] = [] + + def create(self, **kwargs: Any) -> _Response: + self.calls.append(kwargs) + return self._responses[len(self.calls) - 1] + + +class _FakeClient: + def __init__(self, responses: List[_Response]) -> None: + self.messages = _FakeMessages(responses) + + +def _text_response(text: str) -> _Response: + return _Response( + content=[_Block(type='text', text=text)], + stop_reason='end_turn', + usage=_Usage(input_tokens=10, output_tokens=5, cache_read_input_tokens=0, cache_creation_input_tokens=0), + ) + + +def test_defaults_used_when_not_overridden() -> None: + client = _FakeClient([_text_response('hello')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + provider.chat(messages=[LLMMessage(role='user', content='hi')]) + + request = client.messages.calls[0] + assert request['model'] == DEFAULT_MODEL + assert request['max_tokens'] == DEFAULT_MAX_TOKENS + assert request['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}]}] + assert 'tools' not in request + assert 'temperature' not in request + + +def test_config_overrides_model_and_max_tokens() -> None: + client = _FakeClient([_text_response('hello')]) + config = Config({'LLM_ANTHROPIC_MODEL': 'claude-test', 'LLM_ANTHROPIC_MAX_TOKENS': 256}) + provider = AnthropicLLMProvider(config, client=client) + + provider.chat(messages=[LLMMessage(role='user', content='hi')]) + + request = client.messages.calls[0] + assert request['model'] == 'claude-test' + assert request['max_tokens'] == 256 + + +def test_system_prompt_and_role_system_messages_folded() -> None: + client = _FakeClient([_text_response('ok')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + provider.chat( + messages=[ + LLMMessage(role='system', content='from message'), + LLMMessage(role='user', content='hi'), + ], + system='from arg', + ) + + request = client.messages.calls[0] + assert request['system'] == 'from arg\n\nfrom message' + # system messages are not surfaced as conversation messages + assert request['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}]}] + + +def test_per_call_overrides_and_passthrough_params() -> None: + client = _FakeClient([_text_response('ok')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + provider.chat( + messages=[LLMMessage(role='user', content='hi')], + model='claude-override', + max_tokens=42, + temperature=0.3, + top_p=0.9, + ) + + request = client.messages.calls[0] + assert request['model'] == 'claude-override' + assert request['max_tokens'] == 42 + assert request['temperature'] == 0.3 + assert request['top_p'] == 0.9 + + +def test_tools_and_cache_control_translation() -> None: + client = _FakeClient([_text_response('ok')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + tool = ToolDefinition( + name='lookup_user', + description='Look up a user by id', + input_schema={'type': 'object', 'properties': {'id': {'type': 'integer'}}}, + ) + provider.chat( + messages=[LLMMessage(role='user', content='hi', cache_control=CacheControl(ttl='1h'))], + tools=[tool], + ) + + request = client.messages.calls[0] + assert request['tools'] == [ + { + 'name': 'lookup_user', + 'description': 'Look up a user by id', + 'input_schema': {'type': 'object', 'properties': {'id': {'type': 'integer'}}}, + } + ] + # cache_control is attached to the last content block of the message. + block = request['messages'][0]['content'][-1] + assert block['cache_control'] == {'type': 'ephemeral', 'ttl': '1h'} + + +def test_response_with_tool_use_is_parsed() -> None: + response = _Response( + content=[ + _Block(type='text', text='let me check'), + _Block(type='tool_use', id='call_1', name='lookup_user', input={'id': 7}), + ], + stop_reason='tool_use', + usage=_Usage( + input_tokens=20, + output_tokens=8, + cache_read_input_tokens=3, + cache_creation_input_tokens=4, + ), + ) + client = _FakeClient([response]) + provider = AnthropicLLMProvider(Config({}), client=client) + + result = provider.chat(messages=[LLMMessage(role='user', content='who is user 7?')]) + + assert result.text == 'let me check' + assert result.stop_reason == 'tool_use' + assert result.tool_calls == [ToolCall(id='call_1', name='lookup_user', arguments={'id': 7})] + assert result.usage is not None + assert result.usage.input_tokens == 20 + assert result.usage.output_tokens == 8 + assert result.usage.cache_read_tokens == 3 + assert result.usage.cache_write_tokens == 4 + assert result.raw is response + + +def test_full_tool_call_cycle() -> None: + first = _Response( + content=[_Block(type='tool_use', id='call_1', name='lookup_user', input={'id': 7})], + stop_reason='tool_use', + usage=_Usage(input_tokens=1, output_tokens=1, cache_read_input_tokens=0, cache_creation_input_tokens=0), + ) + second = _text_response('User 7 is Ada.') + client = _FakeClient([first, second]) + provider = AnthropicLLMProvider(Config({}), client=client) + + # Round 1: model requests a tool call. + first_result = provider.chat(messages=[LLMMessage(role='user', content='who is user 7?')]) + assert first_result.tool_calls[0].id == 'call_1' + + # Round 2: feed the tool result back and get a final answer. + second_result = provider.chat( + messages=[ + LLMMessage(role='user', content='who is user 7?'), + LLMMessage(role='assistant', tool_calls=list(first_result.tool_calls)), + LLMMessage( + role='tool', + tool_results=[ToolResult(tool_call_id='call_1', content='Ada')], + ), + ] + ) + assert second_result.text == 'User 7 is Ada.' + + # Verify the tool_use and tool_result blocks were serialized as Anthropic expects. + second_request = client.messages.calls[1] + roles = [m['role'] for m in second_request['messages']] + assert roles == ['user', 'assistant', 'user'] + + assistant_block = second_request['messages'][1]['content'][0] + assert assistant_block == { + 'type': 'tool_use', + 'id': 'call_1', + 'name': 'lookup_user', + 'input': {'id': 7}, + } + tool_result_block = second_request['messages'][2]['content'][0] + assert tool_result_block == { + 'type': 'tool_result', + 'tool_use_id': 'call_1', + 'content': 'Ada', + 'is_error': False, + } + + +def test_missing_sdk_raises_clear_error() -> None: + # No client injected and the `anthropic` package is not installed in the workspace, + # so building the client should fail with a helpful message. + provider = AnthropicLLMProvider(Config({})) + try: + provider.chat(messages=[LLMMessage(role='user', content='hi')]) + except RuntimeError as exc: + assert 'anthropic' in str(exc) + else: + raise AssertionError('expected a RuntimeError when the anthropic SDK is missing') diff --git a/example_plugins/src/register_plugins.py b/example_plugins/src/register_plugins.py index 369b237f..a45e339c 100644 --- a/example_plugins/src/register_plugins.py +++ b/example_plugins/src/register_plugins.py @@ -1,8 +1,10 @@ from typing import Any, Sequence, Type +from llm.anthropic_provider import AnthropicLLMProvider from osprey.engine.udf.base import UDFBase from osprey.worker.adaptor.plugin_manager import hookimpl_osprey from osprey.worker.lib.config import Config +from osprey.worker.lib.llm.base import BaseLLMProvider from osprey.worker.lib.storage.labels import LabelsServiceBase from osprey.worker.sinks.sink.output_sink import BaseOutputSink, StdoutOutputSink from services.labels_service import PostgresLabelsService @@ -24,3 +26,14 @@ def register_output_sinks(config: Config) -> Sequence[BaseOutputSink]: def register_labels_service_or_provider(config: Config) -> LabelsServiceBase: """Register a PostgreSQL-backed labels service.""" return PostgresLabelsService() + + +@hookimpl_osprey +def register_llm_provider(config: Config) -> BaseLLMProvider: + """Register a direct Anthropic API LLM provider. + + Requires the ``anthropic`` SDK (installed manually, see + :mod:`llm.anthropic_provider`) and an API key, but only when the provider is + actually invoked. + """ + return AnthropicLLMProvider(config) diff --git a/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py b/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py index 4f839669..84856748 100644 --- a/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py +++ b/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from osprey.worker.lib.config import Config + from osprey.worker.lib.llm.base import BaseLLMProvider from osprey.worker.lib.storage.stored_execution_result import ExecutionResultStore from osprey.worker.sinks.sink.input_stream import BaseInputStream from osprey.worker.sinks.sink.output_sink import BaseOutputSink @@ -55,6 +56,17 @@ def register_execution_result_store(config: Config) -> ExecutionResultStore: raise NotImplementedError('register_execution_result_store must be implemented by the plugin') +@hookspec(firstresult=True) +def register_llm_provider(config: Config) -> BaseLLMProvider: + """Register an LLM API provider used by AI-assisted features (e.g. natural-language + query building). + + Only the first registered provider is used (``firstresult=True``), mirroring the + other single-provider hooks. Return a concrete :class:`BaseLLMProvider`. + """ + raise NotImplementedError('register_llm_provider must be implemented by the plugin') + + @hookspec(firstresult=True) def register_labels_service_or_provider(config: Config) -> LabelsServiceBase | LabelsProvider: """Register a labels service or labels provider. This can be achieved by implementing a labels diff --git a/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py b/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py index ea1be293..366097d0 100644 --- a/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py +++ b/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from osprey.worker.lib.config import Config + from osprey.worker.lib.llm.base import BaseLLMProvider hookimpl_osprey: pluggy.HookimplMarker = pluggy.HookimplMarker(OSPREY_ADAPTOR) @@ -156,6 +157,22 @@ def bootstrap_input_stream(config: Config) -> BaseInputStream[BaseAckingContext[ return None +def bootstrap_llm_provider(config: Config) -> BaseLLMProvider | None: + """Get the LLM API provider from plugins, if one is registered. + + The hook uses ``firstresult=True``, so at most one provider is returned. + Returns ``None`` when no plugin registers ``register_llm_provider``, making it + always safe for callers to check before use. + """ + load_all_osprey_plugins() + + provider = plugin_manager.hook.register_llm_provider(config=config) + if provider: + return provider + else: + return None + + def bootstrap_execution_result_store(config: Config): """Get the execution result storage backend from plugins.""" load_all_osprey_plugins() diff --git a/osprey_worker/src/osprey/worker/lib/llm/__init__.py b/osprey_worker/src/osprey/worker/lib/llm/__init__.py new file mode 100644 index 00000000..db067847 --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/__init__.py @@ -0,0 +1,30 @@ +"""Vendor-neutral LLM provider interface for AI-assisted Osprey features. + +See :mod:`osprey.worker.lib.llm.base` for the interface and dataclasses. Concrete +providers are registered through the ``register_llm_provider`` plugin hook and +obtained via ``osprey.worker.adaptor.plugin_manager.bootstrap_llm_provider``. +""" + +from osprey.worker.lib.llm.base import ( + BaseLLMProvider, + CacheControl, + LLMMessage, + LLMResponse, + LLMUsage, + Role, + ToolCall, + ToolDefinition, + ToolResult, +) + +__all__ = [ + 'BaseLLMProvider', + 'CacheControl', + 'LLMMessage', + 'LLMResponse', + 'LLMUsage', + 'Role', + 'ToolCall', + 'ToolDefinition', + 'ToolResult', +] diff --git a/osprey_worker/src/osprey/worker/lib/llm/base.py b/osprey_worker/src/osprey/worker/lib/llm/base.py new file mode 100644 index 00000000..5ab4d07e --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/base.py @@ -0,0 +1,143 @@ +"""Vendor-neutral interface for LLM API providers used by AI-assisted Osprey features. + +This module defines a small, provider-agnostic surface for chat-style LLM calls, +designed for tool calling from day one: tool definitions go in, tool-call requests +come back out, and tool results are fed back in on a subsequent call. Concrete +providers (e.g. a direct Anthropic implementation) translate these dataclasses to +and from their vendor SDKs and are registered via the ``register_llm_provider`` +plugin hook. + +Streaming is intentionally out of scope here; a future ``chat_stream`` can be added +without breaking this interface. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Literal, Optional, Sequence + +Role = Literal['system', 'user', 'assistant', 'tool'] + + +@dataclass +class ToolDefinition: + """A tool the model may call, described in a vendor-neutral way.""" + + name: str + description: str + input_schema: dict[str, Any] + """JSON Schema describing the tool's input arguments.""" + + +@dataclass +class ToolCall: + """A request, emitted by the model, to invoke a tool with given arguments.""" + + id: str + name: str + arguments: dict[str, Any] + + +@dataclass +class ToolResult: + """The result of executing a tool, fed back to the model on the next call.""" + + tool_call_id: str + content: str + is_error: bool = False + + +@dataclass +class CacheControl: + """Optional prompt-caching hint. + + Vendor-neutral; providers map this onto their own caching primitives (e.g. + Anthropic's ephemeral cache breakpoints). A ``None`` field means "use the + provider default". + """ + + ttl: Optional[str] = None + """Cache time-to-live, e.g. ``'5m'`` or ``'1h'``. ``None`` = provider default.""" + + +@dataclass +class LLMMessage: + """A single message in a conversation. + + ``tool_calls`` carries assistant-emitted tool invocation requests, while + ``tool_results`` carries the outputs of previously requested tools being fed + back in. A given message typically uses one or the other depending on ``role``. + """ + + role: Role + content: Optional[str] = None + tool_calls: Sequence[ToolCall] = field(default_factory=list) + tool_results: Sequence[ToolResult] = field(default_factory=list) + cache_control: Optional[CacheControl] = None + + +@dataclass +class LLMUsage: + """Token accounting for a single response, where the provider reports it.""" + + input_tokens: int = 0 + output_tokens: int = 0 + cache_read_tokens: int = 0 + cache_write_tokens: int = 0 + + +@dataclass +class LLMResponse: + """A single non-streaming response from a provider. + + ``text`` is the concatenated assistant text. ``tool_calls`` is non-empty when + the model is requesting tool execution (typically with ``stop_reason`` + indicating a tool-use stop). ``raw`` holds the untouched vendor response for + callers that need provider-specific details. + """ + + text: str + tool_calls: Sequence[ToolCall] = field(default_factory=list) + stop_reason: Optional[str] = None + usage: Optional[LLMUsage] = None + raw: Any = None + + +class BaseLLMProvider(ABC): + """Interface implemented by LLM API providers. + + Only a single provider may be registered per Osprey deployment (the + ``register_llm_provider`` hook uses ``firstresult=True``). + """ + + @abstractmethod + def chat( + self, + *, + messages: Sequence[LLMMessage], + system: Optional[str] = None, + tools: Optional[Sequence[ToolDefinition]] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **params: Any, + ) -> LLMResponse: + """Send a chat completion request and return a single response. + + Args: + messages: The conversation so far, oldest first. + system: Optional system prompt. Providers may also accept a leading + ``system`` message; prefer this argument for clarity. + tools: Optional tool definitions the model is allowed to call. + model: Provider model identifier. ``None`` uses the provider default. + max_tokens: Maximum tokens to generate. ``None`` uses the provider default. + temperature: Sampling temperature. ``None`` uses the provider default. + **params: Provider-specific passthrough parameters. + + Returns: + An :class:`LLMResponse`. When the model requests tool execution, + ``tool_calls`` is populated and the caller is expected to run the + tools and call ``chat`` again with the results appended. + """ + ... diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/__init__.py b/osprey_worker/src/osprey/worker/lib/llm/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/test_base.py b/osprey_worker/src/osprey/worker/lib/llm/tests/test_base.py new file mode 100644 index 00000000..6959aab3 --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/tests/test_base.py @@ -0,0 +1,103 @@ +"""Unit tests for the vendor-neutral LLM provider dataclasses and interface.""" + +from typing import Any, Optional, Sequence + +from osprey.worker.lib.llm import ( + BaseLLMProvider, + CacheControl, + LLMMessage, + LLMResponse, + LLMUsage, + ToolCall, + ToolDefinition, + ToolResult, +) + + +def test_message_defaults_are_independent() -> None: + a = LLMMessage(role='user', content='hi') + b = LLMMessage(role='user', content='bye') + + # Mutable default factories must not be shared across instances. + assert a.tool_calls is not b.tool_calls + assert a.tool_results is not b.tool_results + assert list(a.tool_calls) == [] + assert a.cache_control is None + + +def test_tool_definition_round_trip() -> None: + schema = {'type': 'object', 'properties': {'q': {'type': 'string'}}} + tool = ToolDefinition(name='search', description='Search the docs', input_schema=schema) + + assert tool.name == 'search' + assert tool.description == 'Search the docs' + assert tool.input_schema == schema + + +def test_tool_call_and_result_shapes() -> None: + call = ToolCall(id='call_1', name='search', arguments={'q': 'osprey'}) + result = ToolResult(tool_call_id='call_1', content='found it') + + assert call.arguments == {'q': 'osprey'} + assert result.tool_call_id == call.id + assert result.is_error is False + + err = ToolResult(tool_call_id='call_2', content='boom', is_error=True) + assert err.is_error is True + + +def test_assistant_message_with_tool_calls() -> None: + call = ToolCall(id='c1', name='lookup', arguments={'id': 5}) + message = LLMMessage(role='assistant', content=None, tool_calls=[call]) + + assert message.content is None + assert list(message.tool_calls) == [call] + + +def test_cache_control_and_usage_defaults() -> None: + assert CacheControl().ttl is None + assert CacheControl(ttl='1h').ttl == '1h' + + usage = LLMUsage() + assert usage.input_tokens == 0 + assert usage.output_tokens == 0 + assert usage.cache_read_tokens == 0 + assert usage.cache_write_tokens == 0 + + +def test_response_defaults() -> None: + response = LLMResponse(text='hello') + assert response.text == 'hello' + assert list(response.tool_calls) == [] + assert response.stop_reason is None + assert response.usage is None + assert response.raw is None + + +def test_base_provider_is_abstract_and_subclassable() -> None: + # BaseLLMProvider cannot be instantiated directly. + try: + BaseLLMProvider() # type: ignore[abstract] + except TypeError: + pass + else: + raise AssertionError('expected BaseLLMProvider to be abstract') + + class EchoProvider(BaseLLMProvider): + def chat( + self, + *, + messages: Sequence[LLMMessage], + system: Optional[str] = None, + tools: Optional[Sequence[ToolDefinition]] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **params: Any, + ) -> LLMResponse: + last = messages[-1] + return LLMResponse(text=last.content or '') + + provider = EchoProvider() + response = provider.chat(messages=[LLMMessage(role='user', content='ping')]) + assert response.text == 'ping' diff --git a/pyproject.toml b/pyproject.toml index e005dce5..a064d896 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,7 +229,7 @@ ignore_unused = [ ] [tool.pytest.ini_options] -testpaths = ["osprey_worker"] +testpaths = ["osprey_worker", "example_plugins"] [tool.mypy] plugins = ["pydantic.mypy", "sqlalchemy.ext.mypy.plugin"] @@ -282,7 +282,9 @@ module = "pydantic.main" implicit_reexport = true [[tool.mypy.overrides]] -module = ["jsonpath_rw", "minio.*"] +# `anthropic` is an optional, lazily-imported SDK used only by the example LLM +# provider; it is not installed in the workspace (see example_plugins/pyproject.toml). +module = ["jsonpath_rw", "minio.*", "anthropic.*"] ignore_missing_imports = true # third party packages we don't care about From c84d1badb1a369a9ab0f3838c1c07eda2471f8a5 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 07:15:31 +0000 Subject: [PATCH 2/8] Cleanup: correct stale anthropic-missing error message The RuntimeError referenced an example_plugins[llm] extra that no longer exists (anthropic isn't a declared dependency due to the typing-extensions conflict). Point users at the manual install instead. --- example_plugins/src/llm/anthropic_provider.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py index f794541e..91747e1d 100644 --- a/example_plugins/src/llm/anthropic_provider.py +++ b/example_plugins/src/llm/anthropic_provider.py @@ -59,7 +59,8 @@ def _get_client(self) -> 'Anthropic': except ImportError as exc: # pragma: no cover - exercised only without the optional dep raise RuntimeError( "The 'anthropic' package is required to use AnthropicLLMProvider. " - "Install it with the optional extra, e.g. `uv pip install 'example_plugins[llm]'`." + 'It is not a declared workspace dependency (it conflicts with the pinned ' + 'typing-extensions), so install it manually, e.g. `uv pip install anthropic`.' ) from exc api_key = self._config.get_optional_str('LLM_ANTHROPIC_API_KEY') or os.environ.get('ANTHROPIC_API_KEY') From 9cdcd25ec445e76568637d6e9b5683b7e7efef8d Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 17:33:33 +0000 Subject: [PATCH 3/8] Address code-review findings on the LLM provider hook - Preserve system-prompt cache_control: _build_system now emits the Anthropic block form (list of text blocks with cache_control) when any system part carries a breakpoint, instead of always flattening to a string that drops it. - Avoid falsy-or traps: model/max_tokens use 'is not None' so explicit '' / 0 pass through instead of silently becoming defaults (matches temperature). - Degrade gracefully on a non-dict tool_use 'input' instead of crashing. - Extract _cache_control_dict helper (de-dupes the message/system cache mapping) and note the extended-cache-ttl beta-header requirement for non-default ttl. - Clarify bootstrap_llm_provider docstring: registered != ready (SDK/creds may be validated lazily on first chat()). - Make the missing-SDK test deterministic via monkeypatch (sys.modules) so it no longer depends on anthropic being absent now that example_plugins is in CI testpaths; add regression tests for the three fixes above. --- example_plugins/src/llm/anthropic_provider.py | 69 ++++++++++++++----- .../src/llm/tests/test_anthropic_provider.py | 63 ++++++++++++++--- .../osprey/worker/adaptor/plugin_manager.py | 6 +- 3 files changed, 109 insertions(+), 29 deletions(-) diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py index 91747e1d..2e46eace 100644 --- a/example_plugins/src/llm/anthropic_provider.py +++ b/example_plugins/src/llm/anthropic_provider.py @@ -21,11 +21,12 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Tuple, Union from osprey.worker.lib.config import Config from osprey.worker.lib.llm.base import ( BaseLLMProvider, + CacheControl, LLMMessage, LLMResponse, LLMUsage, @@ -80,14 +81,17 @@ def chat( **params: Any, ) -> LLMResponse: request: Dict[str, Any] = { - 'model': model or self._default_model, - 'max_tokens': max_tokens or self._default_max_tokens, + # Use `is not None` rather than `or` so an explicit max_tokens=0 or + # model='' is passed through (and rejected by the API) instead of + # silently falling back to the default. + 'model': model if model is not None else self._default_model, + 'max_tokens': max_tokens if max_tokens is not None else self._default_max_tokens, 'messages': self._to_anthropic_messages(messages), } - system_text = self._collect_system_text(system, messages) - if system_text is not None: - request['system'] = system_text + system_value = self._build_system(system, messages) + if system_value is not None: + request['system'] = system_value if tools: request['tools'] = [self._to_anthropic_tool(tool) for tool in tools] @@ -103,19 +107,46 @@ def chat( # --- request translation ------------------------------------------------ - @staticmethod - def _collect_system_text(system: Optional[str], messages: Sequence[LLMMessage]) -> Optional[str]: - parts: List[str] = [] - if system: - parts.append(system) + @classmethod + def _build_system( + cls, system: Optional[str], messages: Sequence[LLMMessage] + ) -> Union[str, List[Dict[str, Any]], None]: # Anthropic carries the system prompt as a top-level field, not a message, - # so fold any role='system' messages into it. + # so the `system` argument and any role='system' messages are folded here. + # Each part keeps its own optional cache_control breakpoint. + parts: List[Tuple[str, Optional[CacheControl]]] = [] + if system: + parts.append((system, None)) for message in messages: if message.role == 'system' and message.content: - parts.append(message.content) + parts.append((message.content, message.cache_control)) + if not parts: return None - return '\n\n'.join(parts) + + # When nothing needs a cache breakpoint, the simple string form suffices. + if all(cache_control is None for _, cache_control in parts): + return '\n\n'.join(text for text, _ in parts) + + # Otherwise emit the structured block form so per-part cache_control is + # preserved (a string `system` cannot carry cache breakpoints). + blocks: List[Dict[str, Any]] = [] + for text, cache_control in parts: + block: Dict[str, Any] = {'type': 'text', 'text': text} + if cache_control is not None: + block['cache_control'] = cls._cache_control_dict(cache_control) + blocks.append(block) + return blocks + + @staticmethod + def _cache_control_dict(cache_control: CacheControl) -> Dict[str, Any]: + result: Dict[str, Any] = {'type': 'ephemeral'} + if cache_control.ttl is not None: + # A non-default ttl (e.g. '1h') requires the Anthropic extended-cache-ttl + # beta header on the client, or the API rejects the request; the default + # 5m ephemeral cache needs no header. + result['ttl'] = cache_control.ttl + return result @staticmethod def _to_anthropic_tool(tool: ToolDefinition) -> Dict[str, Any]: @@ -138,10 +169,7 @@ def _to_anthropic_messages(cls, messages: Sequence[LLMMessage]) -> List[Dict[str continue if message.cache_control is not None: - cache_control: Dict[str, Any] = {'type': 'ephemeral'} - if message.cache_control.ttl is not None: - cache_control['ttl'] = message.cache_control.ttl - blocks[-1]['cache_control'] = cache_control + blocks[-1]['cache_control'] = cls._cache_control_dict(message.cache_control) # Tool results are surfaced to Anthropic as a user-role message. role = 'user' if message.role == 'tool' else message.role @@ -189,11 +217,14 @@ def _from_anthropic_response(response: Any) -> LLMResponse: if block_type == 'text': text_parts.append(getattr(block, 'text', '') or '') elif block_type == 'tool_use': + raw_input = getattr(block, 'input', None) tool_calls.append( ToolCall( id=getattr(block, 'id', ''), name=getattr(block, 'name', ''), - arguments=dict(getattr(block, 'input', {}) or {}), + # Degrade gracefully (like the other fields) if the SDK ever + # hands back a non-dict input rather than crashing. + arguments=dict(raw_input) if isinstance(raw_input, dict) else {}, ) ) diff --git a/example_plugins/src/llm/tests/test_anthropic_provider.py b/example_plugins/src/llm/tests/test_anthropic_provider.py index 220eba17..555529a5 100644 --- a/example_plugins/src/llm/tests/test_anthropic_provider.py +++ b/example_plugins/src/llm/tests/test_anthropic_provider.py @@ -4,8 +4,10 @@ vendor-neutral <-> Anthropic translation, including a full tool-call cycle. """ +import sys from typing import Any, Dict, List +import pytest from osprey.worker.lib.config import Config from osprey.worker.lib.llm.base import ( CacheControl, @@ -99,11 +101,43 @@ def test_system_prompt_and_role_system_messages_folded() -> None: ) request = client.messages.calls[0] + # With no cache_control anywhere, system stays the simple string form. assert request['system'] == 'from arg\n\nfrom message' # system messages are not surfaced as conversation messages assert request['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hi'}]}] +def test_system_cache_control_emits_block_form() -> None: + client = _FakeClient([_text_response('ok')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + provider.chat( + messages=[ + LLMMessage(role='system', content='cached rules', cache_control=CacheControl(ttl='1h')), + LLMMessage(role='user', content='hi'), + ], + system='plain preamble', + ) + + request = client.messages.calls[0] + # A system part carrying cache_control forces the structured block form so the + # breakpoint is preserved rather than silently dropped. + assert request['system'] == [ + {'type': 'text', 'text': 'plain preamble'}, + {'type': 'text', 'text': 'cached rules', 'cache_control': {'type': 'ephemeral', 'ttl': '1h'}}, + ] + + +def test_explicit_zero_max_tokens_is_passed_through() -> None: + client = _FakeClient([_text_response('ok')]) + provider = AnthropicLLMProvider(Config({}), client=client) + + provider.chat(messages=[LLMMessage(role='user', content='hi')], max_tokens=0) + + # An explicit 0 must not be swallowed by the default fallback. + assert client.messages.calls[0]['max_tokens'] == 0 + + def test_per_call_overrides_and_passthrough_params() -> None: client = _FakeClient([_text_response('ok')]) provider = AnthropicLLMProvider(Config({}), client=client) @@ -228,13 +262,26 @@ def test_full_tool_call_cycle() -> None: } -def test_missing_sdk_raises_clear_error() -> None: - # No client injected and the `anthropic` package is not installed in the workspace, - # so building the client should fail with a helpful message. +def test_non_dict_tool_input_degrades_to_empty_args() -> None: + response = _Response( + content=[_Block(type='tool_use', id='call_1', name='lookup_user', input='not-a-dict')], + stop_reason='tool_use', + usage=_Usage(input_tokens=1, output_tokens=1, cache_read_input_tokens=0, cache_creation_input_tokens=0), + ) + client = _FakeClient([response]) + provider = AnthropicLLMProvider(Config({}), client=client) + + result = provider.chat(messages=[LLMMessage(role='user', content='go')]) + + # A malformed (non-dict) tool input degrades to empty args instead of crashing. + assert result.tool_calls == [ToolCall(id='call_1', name='lookup_user', arguments={})] + + +def test_missing_sdk_raises_clear_error(monkeypatch: pytest.MonkeyPatch) -> None: + # Force `import anthropic` to fail deterministically regardless of whether the + # SDK is installed (example_plugins is in CI testpaths now), so this never + # depends on the environment or makes a real network call. + monkeypatch.setitem(sys.modules, 'anthropic', None) provider = AnthropicLLMProvider(Config({})) - try: + with pytest.raises(RuntimeError, match='anthropic'): provider.chat(messages=[LLMMessage(role='user', content='hi')]) - except RuntimeError as exc: - assert 'anthropic' in str(exc) - else: - raise AssertionError('expected a RuntimeError when the anthropic SDK is missing') diff --git a/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py b/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py index 366097d0..313e848c 100644 --- a/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py +++ b/osprey_worker/src/osprey/worker/adaptor/plugin_manager.py @@ -161,8 +161,10 @@ def bootstrap_llm_provider(config: Config) -> BaseLLMProvider | None: """Get the LLM API provider from plugins, if one is registered. The hook uses ``firstresult=True``, so at most one provider is returned. - Returns ``None`` when no plugin registers ``register_llm_provider``, making it - always safe for callers to check before use. + Returns ``None`` when no plugin registers ``register_llm_provider``, so callers + should null-check before use. Note that a returned provider is *registered* but + not necessarily *ready*: a provider may defer validating its backing SDK or + credentials until the first ``chat()`` call. """ load_all_osprey_plugins() From c12248da25e1b5187efaca4a9cdf8a1fdc4f138f Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 17:38:57 +0000 Subject: [PATCH 4/8] Fix stale docs from cycle-2 review - Module docstring no longer references the removed example_plugins[llm] extra; points to manual 'uv pip install anthropic' and the pyproject NOTE. - base.py chat() docstring clarifies that to cache the system prompt you pass a role='system' message with cache_control (the plain system= arg can't). --- example_plugins/src/llm/anthropic_provider.py | 8 +++++--- osprey_worker/src/osprey/worker/lib/llm/base.py | 5 ++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py index 2e46eace..ec44a4fc 100644 --- a/example_plugins/src/llm/anthropic_provider.py +++ b/example_plugins/src/llm/anthropic_provider.py @@ -5,9 +5,11 @@ ``ToolDefinition`` types into Anthropic's request format, and maps the response (including ``tool_use`` blocks) back into ``LLMResponse`` / ``ToolCall``. -The ``anthropic`` SDK is an optional dependency (``example_plugins[llm]``). It is -imported lazily so the base example package, and Osprey's CI, do not require the -SDK, an API key, or network access unless this provider is actually used. +The ``anthropic`` SDK is imported lazily so the base example package, and Osprey's +CI, do not require the SDK, an API key, or network access unless this provider is +actually used. It is intentionally not a declared dependency (it conflicts with the +pinned ``typing-extensions``; see ``example_plugins/pyproject.toml``), so install it +manually to run the provider: ``uv pip install anthropic``. Configuration (via Osprey ``Config`` or environment): diff --git a/osprey_worker/src/osprey/worker/lib/llm/base.py b/osprey_worker/src/osprey/worker/lib/llm/base.py index 5ab4d07e..7951ff56 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/base.py +++ b/osprey_worker/src/osprey/worker/lib/llm/base.py @@ -128,7 +128,10 @@ def chat( Args: messages: The conversation so far, oldest first. system: Optional system prompt. Providers may also accept a leading - ``system`` message; prefer this argument for clarity. + ``system`` message; prefer this argument for clarity. To attach a + cache breakpoint to the system prompt, pass it instead as a + ``role='system'`` message with ``cache_control`` set (this plain + string cannot carry one). tools: Optional tool definitions the model is allowed to call. model: Provider model identifier. ``None`` uses the provider default. max_tokens: Maximum tokens to generate. ``None`` uses the provider default. From d2a586aff1c71ebb9ec547b976c7ceae523ccd2b Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 18:32:55 +0000 Subject: [PATCH 5/8] Address PR review comments on the LLM provider hook - Make anthropic a real dependency of example_plugins by bumping the root typing-extensions pin (==4.6.3 -> >=4.14.0; resolves to 4.15.0). Removes the 'not declared / install manually' workaround (pyproject NOTE, mypy override comment, anthropic.* mypy entry, DEVELOPMENT.md manual-install note). - Use the standard OSPREY_ prefix for config keys (OSPREY_LLM_ANTHROPIC_{API_KEY,MODEL,MAX_TOKENS}). - Default to claude-sonnet-4-6 (verified current Sonnet 4.6 API id). - Don't register an LLM provider by default; an LLM provider is optional and may be unregistered (bootstrap_llm_provider now returns None here). - base.py chat(): raise NotImplementedError instead of a no-effect '...' (CodeQL 'statement has no effect'). - Drop 'mirroring other hooks' language from the hookspec docstring; drop a stray 'e.g.' in DEVELOPMENT.md. - Tests updated for OSPREY_ keys; drop the now-obsolete missing-SDK test. --- docs/DEVELOPMENT.md | 13 +- example_plugins/pyproject.toml | 12 +- example_plugins/src/llm/anthropic_provider.py | 34 ++- .../src/llm/tests/test_anthropic_provider.py | 14 +- example_plugins/src/register_plugins.py | 15 +- .../worker/adaptor/hookspecs/osprey_hooks.py | 8 +- .../src/osprey/worker/lib/llm/base.py | 2 +- pyproject.toml | 6 +- uv.lock | 202 +++++++++++++++++- 9 files changed, 229 insertions(+), 77 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 94c3bb77..ddc20484 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -143,7 +143,7 @@ Implement any subset of these in your plugin's `register_plugins.py`: ### LLM provider hook `register_llm_provider` lets a plugin supply the LLM API client used by AI-assisted -features (e.g. natural-language query building). The interface lives in +features such as natural-language query building. The interface lives in `osprey.worker.lib.llm` and is vendor-neutral and **tool-calling aware**: you pass `ToolDefinition`s in, the model may return `ToolCall`s, and you feed `ToolResult`s back on the next `chat()` call. @@ -163,13 +163,10 @@ returns `None` when no plugin registers one — so callers should null-check. A direct Anthropic implementation is provided as a reference in `example_plugins/src/llm/anthropic_provider.py`, including the request/response and -`tool_use` translation. It imports the `anthropic` SDK lazily; the SDK is **not** -declared as a workspace dependency (it conflicts with the pinned -`typing-extensions`), so install it manually to actually run the provider: - -```bash -uv pip install anthropic -``` +`tool_use` translation. The `anthropic` SDK is a dependency of `example_plugins` +(installed by `uv sync`); set `ANTHROPIC_API_KEY` (or the +`OSPREY_LLM_ANTHROPIC_API_KEY` config key) to use it. The example plugins do **not** +register it by default — add your own `register_llm_provider` hookimpl to enable it. ## Rules diff --git a/example_plugins/pyproject.toml b/example_plugins/pyproject.toml index 335cf25f..7a499460 100644 --- a/example_plugins/pyproject.toml +++ b/example_plugins/pyproject.toml @@ -4,18 +4,10 @@ version = "0.1.0" description = "Example plugins for Osprey" requires-python = ">=3.11" dependencies = [ - "pluggy==1.5.0" + "pluggy==1.5.0", + "anthropic>=0.40.0", ] -# NOTE: The example Anthropic LLM provider (llm/anthropic_provider.py) needs the -# `anthropic` SDK, but it is intentionally NOT declared here. The workspace pins -# `typing-extensions==4.6.3`, while every modern `anthropic` requires a newer -# typing-extensions, so declaring it (even as an optional extra) makes the uv -# workspace lock unsatisfiable. The provider imports `anthropic` lazily and raises -# a clear error if it is missing; install it manually to use the provider, e.g.: -# uv pip install anthropic -# (You may also need to relax the typing-extensions pin in the root workspace.) - [tool.setuptools] package-dir = {"" = "src"} diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py index ec44a4fc..907c9ba1 100644 --- a/example_plugins/src/llm/anthropic_provider.py +++ b/example_plugins/src/llm/anthropic_provider.py @@ -5,19 +5,16 @@ ``ToolDefinition`` types into Anthropic's request format, and maps the response (including ``tool_use`` blocks) back into ``LLMResponse`` / ``ToolCall``. -The ``anthropic`` SDK is imported lazily so the base example package, and Osprey's -CI, do not require the SDK, an API key, or network access unless this provider is -actually used. It is intentionally not a declared dependency (it conflicts with the -pinned ``typing-extensions``; see ``example_plugins/pyproject.toml``), so install it -manually to run the provider: ``uv pip install anthropic``. +The ``anthropic`` SDK is a dependency of ``example_plugins``. The client is built +lazily on first use, so instantiating the provider does not require an API key. Configuration (via Osprey ``Config`` or environment): -- API key: ``LLM_ANTHROPIC_API_KEY`` config key, else the ``ANTHROPIC_API_KEY`` +- API key: ``OSPREY_LLM_ANTHROPIC_API_KEY`` config key, else the ``ANTHROPIC_API_KEY`` environment variable (read by the SDK itself if neither is set explicitly). -- Default model: ``LLM_ANTHROPIC_MODEL`` config key - (default: ``claude-3-5-sonnet-latest``). -- Default max tokens: ``LLM_ANTHROPIC_MAX_TOKENS`` config key (default: ``1024``). +- Default model: ``OSPREY_LLM_ANTHROPIC_MODEL`` config key + (default: ``claude-sonnet-4-6``). +- Default max tokens: ``OSPREY_LLM_ANTHROPIC_MAX_TOKENS`` config key (default: ``1024``). """ from __future__ import annotations @@ -37,36 +34,35 @@ ) if TYPE_CHECKING: - from anthropic import Anthropic + import anthropic -DEFAULT_MODEL = 'claude-3-5-sonnet-latest' +DEFAULT_MODEL = 'claude-sonnet-4-6' DEFAULT_MAX_TOKENS = 1024 class AnthropicLLMProvider(BaseLLMProvider): """A :class:`BaseLLMProvider` that calls the Anthropic Messages API directly.""" - def __init__(self, config: Config, client: Optional['Anthropic'] = None) -> None: + def __init__(self, config: Config, client: Optional[anthropic.Anthropic] = None) -> None: self._config = config - self._default_model = config.get_str('LLM_ANTHROPIC_MODEL', DEFAULT_MODEL) - self._default_max_tokens = config.get_int('LLM_ANTHROPIC_MAX_TOKENS', DEFAULT_MAX_TOKENS) + self._default_model = config.get_str('OSPREY_LLM_ANTHROPIC_MODEL', DEFAULT_MODEL) + self._default_max_tokens = config.get_int('OSPREY_LLM_ANTHROPIC_MAX_TOKENS', DEFAULT_MAX_TOKENS) # Allow injecting a client (used in tests); otherwise build lazily on first use. self._client = client - def _get_client(self) -> 'Anthropic': + def _get_client(self) -> anthropic.Anthropic: if self._client is not None: return self._client try: import anthropic - except ImportError as exc: # pragma: no cover - exercised only without the optional dep + except ImportError as exc: # pragma: no cover - anthropic is a declared dependency raise RuntimeError( "The 'anthropic' package is required to use AnthropicLLMProvider. " - 'It is not a declared workspace dependency (it conflicts with the pinned ' - 'typing-extensions), so install it manually, e.g. `uv pip install anthropic`.' + 'It is a dependency of example_plugins, so run `uv sync` to install it.' ) from exc - api_key = self._config.get_optional_str('LLM_ANTHROPIC_API_KEY') or os.environ.get('ANTHROPIC_API_KEY') + api_key = self._config.get_optional_str('OSPREY_LLM_ANTHROPIC_API_KEY') or os.environ.get('ANTHROPIC_API_KEY') # If api_key is None the SDK still reads ANTHROPIC_API_KEY from the environment itself. self._client = anthropic.Anthropic(api_key=api_key) if api_key else anthropic.Anthropic() return self._client diff --git a/example_plugins/src/llm/tests/test_anthropic_provider.py b/example_plugins/src/llm/tests/test_anthropic_provider.py index 555529a5..fadf57ae 100644 --- a/example_plugins/src/llm/tests/test_anthropic_provider.py +++ b/example_plugins/src/llm/tests/test_anthropic_provider.py @@ -4,10 +4,8 @@ vendor-neutral <-> Anthropic translation, including a full tool-call cycle. """ -import sys from typing import Any, Dict, List -import pytest from osprey.worker.lib.config import Config from osprey.worker.lib.llm.base import ( CacheControl, @@ -78,7 +76,7 @@ def test_defaults_used_when_not_overridden() -> None: def test_config_overrides_model_and_max_tokens() -> None: client = _FakeClient([_text_response('hello')]) - config = Config({'LLM_ANTHROPIC_MODEL': 'claude-test', 'LLM_ANTHROPIC_MAX_TOKENS': 256}) + config = Config({'OSPREY_LLM_ANTHROPIC_MODEL': 'claude-test', 'OSPREY_LLM_ANTHROPIC_MAX_TOKENS': 256}) provider = AnthropicLLMProvider(config, client=client) provider.chat(messages=[LLMMessage(role='user', content='hi')]) @@ -275,13 +273,3 @@ def test_non_dict_tool_input_degrades_to_empty_args() -> None: # A malformed (non-dict) tool input degrades to empty args instead of crashing. assert result.tool_calls == [ToolCall(id='call_1', name='lookup_user', arguments={})] - - -def test_missing_sdk_raises_clear_error(monkeypatch: pytest.MonkeyPatch) -> None: - # Force `import anthropic` to fail deterministically regardless of whether the - # SDK is installed (example_plugins is in CI testpaths now), so this never - # depends on the environment or makes a real network call. - monkeypatch.setitem(sys.modules, 'anthropic', None) - provider = AnthropicLLMProvider(Config({})) - with pytest.raises(RuntimeError, match='anthropic'): - provider.chat(messages=[LLMMessage(role='user', content='hi')]) diff --git a/example_plugins/src/register_plugins.py b/example_plugins/src/register_plugins.py index a45e339c..0d56fb21 100644 --- a/example_plugins/src/register_plugins.py +++ b/example_plugins/src/register_plugins.py @@ -1,10 +1,8 @@ from typing import Any, Sequence, Type -from llm.anthropic_provider import AnthropicLLMProvider from osprey.engine.udf.base import UDFBase from osprey.worker.adaptor.plugin_manager import hookimpl_osprey from osprey.worker.lib.config import Config -from osprey.worker.lib.llm.base import BaseLLMProvider from osprey.worker.lib.storage.labels import LabelsServiceBase from osprey.worker.sinks.sink.output_sink import BaseOutputSink, StdoutOutputSink from services.labels_service import PostgresLabelsService @@ -28,12 +26,7 @@ def register_labels_service_or_provider(config: Config) -> LabelsServiceBase: return PostgresLabelsService() -@hookimpl_osprey -def register_llm_provider(config: Config) -> BaseLLMProvider: - """Register a direct Anthropic API LLM provider. - - Requires the ``anthropic`` SDK (installed manually, see - :mod:`llm.anthropic_provider`) and an API key, but only when the provider is - actually invoked. - """ - return AnthropicLLMProvider(config) +# NOTE: the `register_llm_provider` hook is intentionally NOT implemented here. +# An LLM provider is optional, and we don't want one registered by default. See +# `llm.anthropic_provider.AnthropicLLMProvider` for a reference implementation; a +# deployment that wants it can add its own `register_llm_provider` hookimpl. diff --git a/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py b/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py index 84856748..cd875bd2 100644 --- a/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py +++ b/osprey_worker/src/osprey/worker/adaptor/hookspecs/osprey_hooks.py @@ -58,11 +58,11 @@ def register_execution_result_store(config: Config) -> ExecutionResultStore: @hookspec(firstresult=True) def register_llm_provider(config: Config) -> BaseLLMProvider: - """Register an LLM API provider used by AI-assisted features (e.g. natural-language - query building). + """Register an LLM API provider used by AI-assisted features such as + natural-language query building. - Only the first registered provider is used (``firstresult=True``), mirroring the - other single-provider hooks. Return a concrete :class:`BaseLLMProvider`. + Only the first registered provider is used (``firstresult=True``). Return a + concrete :class:`BaseLLMProvider`. """ raise NotImplementedError('register_llm_provider must be implemented by the plugin') diff --git a/osprey_worker/src/osprey/worker/lib/llm/base.py b/osprey_worker/src/osprey/worker/lib/llm/base.py index 7951ff56..0f42bf5c 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/base.py +++ b/osprey_worker/src/osprey/worker/lib/llm/base.py @@ -143,4 +143,4 @@ def chat( ``tool_calls`` is populated and the caller is expected to run the tools and call ``chat`` again with the results appended. """ - ... + raise NotImplementedError('Subclasses must implement chat().') diff --git a/pyproject.toml b/pyproject.toml index a064d896..f03c23c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ common = [ "tink==1.9.0", "tld==0.12.7", "traitlets==5.14.3", - "typing-extensions==4.6.3", + "typing-extensions>=4.14.0", "typing-inspect==0.9.0", "unidecode==1.3.8", "werkzeug==1.0.1", @@ -282,9 +282,7 @@ module = "pydantic.main" implicit_reexport = true [[tool.mypy.overrides]] -# `anthropic` is an optional, lazily-imported SDK used only by the example LLM -# provider; it is not installed in the workspace (see example_plugins/pyproject.toml). -module = ["jsonpath_rw", "minio.*", "anthropic.*"] +module = ["jsonpath_rw", "minio.*"] ignore_missing_imports = true # third party packages we don't care about diff --git a/uv.lock b/uv.lock index 9bb7ace3..d1daa2ee 100644 --- a/uv.lock +++ b/uv.lock @@ -119,7 +119,7 @@ common = [ { name = "types-six", specifier = "==1.17.0.20250515" }, { name = "types-urllib3", specifier = "==1.26.25.14" }, { name = "types-werkzeug", specifier = "==1.0.9" }, - { name = "typing-extensions", specifier = "==4.6.3" }, + { name = "typing-extensions", specifier = ">=4.14.0" }, { name = "typing-inspect", specifier = "==0.9.0" }, { name = "unidecode", specifier = "==1.3.8" }, { name = "werkzeug", specifier = "==1.0.1" }, @@ -151,6 +151,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl", hash = "sha256:eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d", size = 135811, upload-time = "2025-07-03T09:31:42.253Z" }, ] +[[package]] +name = "anthropic" +version = "0.105.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/46/47581b8c689c743ceabf6a0f9ff48472160900ce802d26c0fb50423997b3/anthropic-0.105.2.tar.gz", hash = "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", size = 853789, upload-time = "2026-05-29T00:21:14.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/75/be0c357e33a5a56c8f9db5b4212f886138d2bf59c0952d858f6b75d710ef/anthropic-0.105.2-py3-none-any.whl", hash = "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230", size = 837507, upload-time = "2026-05-29T00:21:15.528Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + [[package]] name = "argon2-cffi" version = "25.1.0" @@ -469,6 +501,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "dnspython" version = "2.6.1" @@ -478,6 +519,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/a1/8c5287991ddb8d3e4662f71356d9656d91ab3a36618c3dd11b280df0d255/dnspython-2.6.1-py3-none-any.whl", hash = "sha256:5ef3b9680161f6fa89daf8ad451b5f1a33b18ae8a1c6778cdf4b43f08c0a6e50", size = 307696, upload-time = "2024-02-18T18:48:46.786Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "envier" version = "0.5.2" @@ -500,11 +550,15 @@ name = "example-plugins" version = "0.1.0" source = { editable = "example_plugins" } dependencies = [ + { name = "anthropic" }, { name = "pluggy" }, ] [package.metadata] -requires-dist = [{ name = "pluggy", specifier = "==1.5.0" }] +requires-dist = [ + { name = "anthropic", specifier = ">=0.40.0" }, + { name = "pluggy", specifier = "==1.5.0" }, +] [[package]] name = "executing" @@ -925,7 +979,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/a3/1c/c42834d4fee45c5cf2d9546e97e879a1cbcdecfd16eb1a12144dcb91edae/grpcio-1.49.1.tar.gz", hash = "sha256:d4725fc9ec8e8822906ae26bb26f5546891aa7fbc3443de970cc556d43a5c99f", size = 22059239, upload-time = "2022-09-22T03:02:44.376Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/e2/aaccddb8b06637625d847dbb5fe76ec3d15a74d89d983f5202f3666706e3/grpcio-1.49.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:9fb17ff8c0d56099ac6ebfa84f670c5a62228d6b5c695cf21c02160c2ac1446b", size = 73399185, upload-time = "2022-09-22T02:57:56.219Z" }, { url = "https://files.pythonhosted.org/packages/90/0f/4d614d59f500835cbd27cb90743fb6b299098b0f22b8fd058d3586c933c0/grpcio-1.49.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:075f2d06e3db6b48a2157a1bcd52d6cbdca980dd18988fe6afdb41795d51625f", size = 4296299, upload-time = "2022-09-22T02:58:01.417Z" }, { url = "https://files.pythonhosted.org/packages/4d/ea/359a98f8b3b4ff9a2f457a0d20ed81775a64149fbb7617177ed23d9d10c9/grpcio-1.49.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc79b2b37d779ac42341ddef40ad5bf0966a64af412c89fc2b062e3ddabb093f", size = 4656437, upload-time = "2022-09-22T02:58:06.23Z" }, { url = "https://files.pythonhosted.org/packages/fc/89/4952d2dff95f5b95db5943b2d1b55c82a485830b992f52f212b33616b523/grpcio-1.49.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:49b301740cf5bc8fed4fee4c877570189ae3951432d79fa8e524b09353659811", size = 4888051, upload-time = "2022-09-22T02:58:11.411Z" }, @@ -1054,7 +1107,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/6c/e4/3416d25aebc4477141a491fae2c9494c5e0437a706c59103a936aac7d072/grpcio-tools-1.49.1.tar.gz", hash = "sha256:84cc64e5b46bad43d5d7bd2fd772b656eba0366961187a847e908e2cb735db91", size = 2252679, upload-time = "2022-09-22T03:03:00.279Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/c1/ba298fe650b67c9e31a7ad88b2fe1d8d22ff2c6a9e131604054835397dfc/grpcio_tools-1.49.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:9e5c13809ab2f245398e8446c4c3b399a62d591db651e46806cccf52a700452e", size = 36912892, upload-time = "2022-09-22T03:00:51.237Z" }, { url = "https://files.pythonhosted.org/packages/9c/8b/a45a39bf7d1c4956d48179831e4da88c3f6ee14dbdcb273e575bbeb7de20/grpcio_tools-1.49.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:ab3d0ee9623720ee585fdf3753b3755d3144a4a8ae35bca8e3655fa2f41056be", size = 2025040, upload-time = "2022-09-22T03:00:55.219Z" }, { url = "https://files.pythonhosted.org/packages/6d/7f/89dc6036b91f8cbada98b06801ac2f5db60885000feaf88f9d7cabe665b7/grpcio_tools-1.49.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e13b3643e7577a3ec13b79689eb4d7548890b1e104c04b9ed6557a3c3dd452", size = 2370982, upload-time = "2022-09-22T03:00:59.807Z" }, { url = "https://files.pythonhosted.org/packages/01/98/4730bfff6bcd3163db8c3d70689e19a1a5f419152316edfc1f13ff06a5d7/grpcio_tools-1.49.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a64bab81b220c50033f584f57978ebbea575f09c1ccee765cd5c462177988098", size = 2731915, upload-time = "2022-09-22T03:01:05.44Z" }, @@ -1103,6 +1155,15 @@ dependencies = [ { name = "setuptools" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + [[package]] name = "hash-ring" version = "0.0.4" @@ -1115,6 +1176,19 @@ sdist = { hash = "sha256:e30bccd9f6c75972dd6d89a5b5f249c04d7784c138a32323dcbe2df [package.metadata] requires-dist = [{ name = "six", specifier = "~=1.15" }] +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httplib2" version = "0.22.0" @@ -1127,6 +1201,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/6c/d2fbdaaa5959339d53ba38e94c123e4e84b8fbc4b84beb0e70d7c1608486/httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc", size = 96854, upload-time = "2023-03-21T22:29:35.683Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "humanize" version = "4.12.3" @@ -1273,6 +1362,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/c2/1eece8c95ddbc9b1aeb64f5783a9e07a286de42191b7204d67b7496ddf35/Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", size = 125699, upload-time = "2021-01-31T16:33:07.289Z" }, ] +[[package]] +name = "jiter" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/13/daa722f5765c393576f466378f9dfd29d77c9bed939e0688f96afa3601ea/jiter-0.15.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0f862193b8696249d22ec433e85fd2ab0ad9596bc3e45e6c0bc55e8aeba97be2", size = 310899, upload-time = "2026-05-19T10:07:12.89Z" }, + { url = "https://files.pythonhosted.org/packages/7f/82/2d2551829b082f4b6d82b9f939b031fb808a10aab1ec0664f82e150bb9a2/jiter-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1303d4d68a9b051ea90502402063ecf3807da00ad2affa19ca1ae3b90b3c5f67", size = 314963, upload-time = "2026-05-19T10:07:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0a/8b1a51466f7fe9f31dbe4bc7e0ca848674f9825e0f737b929b97e8c60aa7/jiter-0.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:392b8ab019e5502d08aff85c6272209c24bc2cbe706ea82a56368f524236614a", size = 341730, upload-time = "2026-05-19T10:07:15.869Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/e71dea19822e2e404e83992a08c1d6b9b617bb944f28c9c2fbd85d02c91e/jiter-0.15.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:773b6eb282ce11ee19f05f6b2d4404fa308e5bbd353b0b80a0262caad6db2cd7", size = 366214, upload-time = "2026-05-19T10:07:17.259Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/97e1fa539d124a509a00ab7f669289d1c1d236ecabf12948a18f16c91082/jiter-0.15.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2c0c44d569ce0f2850f5c926f8caeb5f245fbc84475aeb36efccc2103e6dbd", size = 459527, upload-time = "2026-05-19T10:07:18.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/7a/4a68d331aef8cf2e2393c14a3aacb635c62aa86071b0229899fb5baaa907/jiter-0.15.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:032396229564bca02440396bd327710719f724f5e7b7e9f7a8eb3faa4a2c2281", size = 375451, upload-time = "2026-05-19T10:07:20.208Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/1c445c2b6f0e30a274dc8082e0c3c7825411cce80d726bccd697c98cc8d3/jiter-0.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3d37768fce7f88dd2a8c6091f2325dea27d30d30d5c6e7a1c0f0af77723b708", size = 349428, upload-time = "2026-05-19T10:07:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/e20d38984fc17a636371bffd2ae0f698124fdc8e75ef969cd2da6ba7cea7/jiter-0.15.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2c9cb907439d20bd0c7d7565ca01ee52234203208433749bae5b516907526928", size = 355405, upload-time = "2026-05-19T10:07:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/94/fa/4d09f814779d0ea80a28ed8e4c6662ec9a4a8ecef0ac52190ebac6262d14/jiter-0.15.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9100ddbec09741cc66feb0fc6773f8bdbd0e3c345689368f260082ff85dcc0cd", size = 393688, upload-time = "2026-05-19T10:07:25.854Z" }, + { url = "https://files.pythonhosted.org/packages/54/9d/8eb5d4fb8bf7e93a75964a5da71a75c67c864baf7fa3f98598187b3c7e57/jiter-0.15.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae1b0d82ac2d987f9ea512b1c9adfcc71a28de3dea3a6039b54d76cffda9901e", size = 520853, upload-time = "2026-05-19T10:07:27.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/5e07874e59e623a943a0acf1552a80d05b70f31b402287a8fc6d7ec634c7/jiter-0.15.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8020c99ec13a7db2b6f96cbe82ef4721c88b426a4892f27478044af0284615ef", size = 551016, upload-time = "2026-05-19T10:07:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/d2d34422143474cadc15b60d482b1c35683dbc5c63c24346ddd0df09bcaf/jiter-0.15.0-cp311-cp311-win32.whl", hash = "sha256:42bfb257930800cf43e7c62c832402c704ab60797c992faf88d20e903eac8f32", size = 209518, upload-time = "2026-05-19T10:07:30.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7d/52778b930e5cc3e52a37d950b1c10494244308b4329b25a0ff0d88303a81/jiter-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:860a74063284a2ae9bfedd694f299cc2c68e2696c5f3d440cc9d18bb81b9dd04", size = 200565, upload-time = "2026-05-19T10:07:32.125Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/d9b4067feb69b3fa6eb0488e1b59e2ad5b463fe39f59e527eab2aca00bb0/jiter-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:37a10c377ce3a4a85f4a67f28b7afe093154cde77eaf248a72e856aa08b4d865", size = 195488, upload-time = "2026-05-19T10:07:33.846Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" }, + { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" }, + { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" }, + { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" }, + { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" }, + { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" }, + { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" }, + { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" }, + { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" }, + { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" }, + { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" }, + { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" }, + { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" }, + { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" }, + { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" }, + { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" }, + { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" }, + { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/43/1fc62172aa98b50a7de9a25554060db510f85c89cfbed0dfe13e1907a139/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:411fa4dfa5a7ae3d11491027ffb9beadec3996010a986862db70d91abba1c750", size = 305585, upload-time = "2026-05-19T10:09:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c4/dd58fcd9e2df83666e5c1c1347bef58ce919cd8efc3ffa38aeea62ce493b/jiter-0.15.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:2b0074e2f56eb2dacca1689760fd2852a068f85a0547a157b82cb4cafeb6768b", size = 306936, upload-time = "2026-05-19T10:09:37.435Z" }, + { url = "https://files.pythonhosted.org/packages/39/86/b695e16f1180c07f43ea98e73ecd21cf63fa2e1b0c1103739013784d11ae/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:913d02d29c9606643418d9ccfc3b72492ab25a6bf7889934e09a3490f8d3438b", size = 342453, upload-time = "2026-05-19T10:09:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/34/56/55d76614af37fe3f22a3347d1e410d2a15da581997cb2da499a625000bb5/jiter-0.15.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b15d3ec9b0449c40e85319bdb4caa8b77ab526e74f5532ed94bec15e2f66822c", size = 345606, upload-time = "2026-05-19T10:09:40.727Z" }, + { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, +] + [[package]] name = "jslog4kube" version = "1.0.6" @@ -2355,6 +2534,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, ] +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "sqlalchemy" version = "1.4.54" @@ -2680,11 +2868,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.6.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/42/56/cfaa7a5281734dadc842f3a22e50447c675a1c5a5b9f6ad8a07b467bffe7/typing_extensions-4.6.3.tar.gz", hash = "sha256:d91d5919357fe7f681a9f2b5b4cb2a5f1ef0a1e9f59c4d8ff0d3491e05c0ffd5", size = 65757, upload-time = "2023-06-01T23:55:36.332Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/86/d9b1518d8e75b346a33eb59fa31bdbbee11459a7e2cc5be502fa779e96c5/typing_extensions-4.6.3-py3-none-any.whl", hash = "sha256:88a4153d8505aabbb4e13aacb7c486c2b4a33ca3b3f807914a9b4c844c471c26", size = 31329, upload-time = "2023-06-01T23:55:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] [[package]] From 70f9ccf1adbb15577b5a01e2f6c1f5f37b92e3cc Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 18:37:42 +0000 Subject: [PATCH 6/8] Add generic tool-calling layer: @tool registry + run_tool_loop Vendor-neutral sugar on top of the provider interface, in osprey.worker.lib.llm: - tools.py: ToolParameter + a ToolRegistry.@tool decorator that compiles to the existing ToolDefinition (JSON Schema). Handlers are plain sync callables (no imposed context object); dispatch(ToolCall) runs the handler and captures unknown-tool / bad-args / handler errors as ToolResult(is_error=True) to feed back to the model. registry.definitions() feeds provider.chat(tools=...). - loop.py: run_tool_loop drives the standard exchange (chat -> dispatch tool_calls -> append role='assistant' tool-use + role='tool' results -> repeat) until the model returns a final answer, or raises ToolLoopLimitExceeded past max_iterations. Input messages are not mutated. - Exported from llm/__init__.py; documented in DEVELOPMENT.md; unit tests for schema building, registration, dispatch (success/error/unknown), and the loop (tool cycle, no-op, limit, no-mutation). --- docs/DEVELOPMENT.md | 33 ++++ .../src/osprey/worker/lib/llm/__init__.py | 12 ++ .../src/osprey/worker/lib/llm/loop.py | 81 ++++++++ .../osprey/worker/lib/llm/tests/test_loop.py | 152 +++++++++++++++ .../osprey/worker/lib/llm/tests/test_tools.py | 141 ++++++++++++++ .../src/osprey/worker/lib/llm/tools.py | 174 ++++++++++++++++++ 6 files changed, 593 insertions(+) create mode 100644 osprey_worker/src/osprey/worker/lib/llm/loop.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py create mode 100644 osprey_worker/src/osprey/worker/lib/llm/tools.py diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ddc20484..0b5e1735 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -168,6 +168,39 @@ A direct Anthropic implementation is provided as a reference in `OSPREY_LLM_ANTHROPIC_API_KEY` config key) to use it. The example plugins do **not** register it by default — add your own `register_llm_provider` hookimpl to enable it. +#### Declaring tools and running a tool loop + +`osprey.worker.lib.llm` includes an optional, vendor-neutral tool-calling layer. +Declare tools with the `@tool` decorator on a `ToolRegistry` (it compiles +`ToolParameter`s into the `ToolDefinition` JSON Schema the provider consumes), then +let `run_tool_loop` drive the call/dispatch/feed-back exchange until the model +returns a final answer. Handlers are plain synchronous callables. + +```python +from osprey.worker.lib.llm import ToolParameter, ToolRegistry, run_tool_loop, LLMMessage + +registry = ToolRegistry() + +@registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='User id')], +) +def lookup_user(id: int) -> dict: + return {'id': id, 'name': '...'} + +response = run_tool_loop( + provider, # any BaseLLMProvider + messages=[LLMMessage(role='user', content='Who is user 7?')], + registry=registry, +) +``` + +`registry.dispatch(tool_call)` runs a single tool and captures errors as a +`ToolResult` with `is_error=True` (fed back to the model rather than aborting). +`run_tool_loop` raises `ToolLoopLimitExceeded` if the model keeps requesting tools +past `max_iterations`. + ## Rules Rules are written in SML, some examples are provided in `example_rules/` with YAML config, the rules are mounted to the worker processes when the containers start via environment variables. ex: diff --git a/osprey_worker/src/osprey/worker/lib/llm/__init__.py b/osprey_worker/src/osprey/worker/lib/llm/__init__.py index db067847..e8ff562d 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/__init__.py +++ b/osprey_worker/src/osprey/worker/lib/llm/__init__.py @@ -3,6 +3,10 @@ See :mod:`osprey.worker.lib.llm.base` for the interface and dataclasses. Concrete providers are registered through the ``register_llm_provider`` plugin hook and obtained via ``osprey.worker.adaptor.plugin_manager.bootstrap_llm_provider``. + +:mod:`osprey.worker.lib.llm.tools` and :mod:`osprey.worker.lib.llm.loop` add an +optional, vendor-neutral tool-calling layer: declare tools with the ``@tool`` +decorator and run the standard tool-execution exchange with ``run_tool_loop``. """ from osprey.worker.lib.llm.base import ( @@ -16,6 +20,8 @@ ToolDefinition, ToolResult, ) +from osprey.worker.lib.llm.loop import ToolLoopLimitExceeded, run_tool_loop +from osprey.worker.lib.llm.tools import Tool, ToolParameter, ToolRegistry, build_input_schema __all__ = [ 'BaseLLMProvider', @@ -27,4 +33,10 @@ 'ToolCall', 'ToolDefinition', 'ToolResult', + 'Tool', + 'ToolParameter', + 'ToolRegistry', + 'build_input_schema', + 'run_tool_loop', + 'ToolLoopLimitExceeded', ] diff --git a/osprey_worker/src/osprey/worker/lib/llm/loop.py b/osprey_worker/src/osprey/worker/lib/llm/loop.py new file mode 100644 index 00000000..7b214809 --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/loop.py @@ -0,0 +1,81 @@ +"""A vendor-neutral tool-call loop. + +``run_tool_loop`` drives the standard agentic exchange: call the provider, and while +it requests tool calls, dispatch them through a :class:`~osprey.worker.lib.llm.tools.ToolRegistry` +and feed the results back, until the model returns a final answer (or an iteration +cap is hit). This is pure mechanism — *which* tools exist and any domain prompting +live with the caller, not here. +""" + +from __future__ import annotations + +from typing import Any, List, Optional, Sequence + +from osprey.worker.lib.llm.base import BaseLLMProvider, LLMMessage, LLMResponse +from osprey.worker.lib.llm.tools import ToolRegistry + +DEFAULT_MAX_ITERATIONS = 10 + + +class ToolLoopLimitExceeded(RuntimeError): + """Raised when the model keeps requesting tools past ``max_iterations``.""" + + def __init__(self, max_iterations: int) -> None: + super().__init__(f'tool-call loop did not converge within {max_iterations} iteration(s)') + self.max_iterations = max_iterations + + +def run_tool_loop( + provider: BaseLLMProvider, + *, + messages: Sequence[LLMMessage], + registry: ToolRegistry, + system: Optional[str] = None, + max_iterations: int = DEFAULT_MAX_ITERATIONS, + **chat_params: Any, +) -> LLMResponse: + """Run a chat/tool-execution loop and return the model's final response. + + Args: + provider: The LLM provider to call. + messages: The initial conversation, oldest first. Not mutated. + registry: Tools available to the model; its definitions are sent on every + turn and its :meth:`~osprey.worker.lib.llm.tools.ToolRegistry.dispatch` + runs any requested calls. + system: Optional system prompt forwarded to the provider. + max_iterations: Maximum number of provider calls before giving up. + **chat_params: Extra provider params (``model``, ``max_tokens``, + ``temperature``, ...) forwarded to every ``chat`` call. + + Returns: + The first :class:`LLMResponse` that requests no further tool calls. + + Raises: + ToolLoopLimitExceeded: If the model is still requesting tools after + ``max_iterations`` provider calls. + """ + if max_iterations < 1: + raise ValueError('max_iterations must be >= 1') + + conversation: List[LLMMessage] = list(messages) + tools = registry.definitions() + + for _ in range(max_iterations): + response = provider.chat(messages=conversation, system=system, tools=tools, **chat_params) + if not response.tool_calls: + return response + + # Echo the assistant's tool-use turn, then feed back the results so the next + # call sees the full exchange (providers require tool results to follow the + # matching tool-use request). + conversation.append( + LLMMessage( + role='assistant', + content=response.text or None, + tool_calls=list(response.tool_calls), + ) + ) + results = [registry.dispatch(tool_call) for tool_call in response.tool_calls] + conversation.append(LLMMessage(role='tool', tool_results=results)) + + raise ToolLoopLimitExceeded(max_iterations) diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py b/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py new file mode 100644 index 00000000..759715b7 --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py @@ -0,0 +1,152 @@ +"""Unit tests for the vendor-neutral tool-call loop.""" + +from typing import Any, List, Optional, Sequence + +from osprey.worker.lib.llm import ( + BaseLLMProvider, + LLMMessage, + LLMResponse, + ToolCall, + ToolDefinition, + ToolLoopLimitExceeded, + ToolParameter, + ToolRegistry, + run_tool_loop, +) + + +class _ScriptedProvider(BaseLLMProvider): + """A provider that returns a fixed list of responses, recording each call.""" + + def __init__(self, responses: Sequence[LLMResponse]) -> None: + self._responses = list(responses) + self.calls: List[List[LLMMessage]] = [] + + def chat( + self, + *, + messages: Sequence[LLMMessage], + system: Optional[str] = None, + tools: Optional[Sequence[ToolDefinition]] = None, + model: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + **params: Any, + ) -> LLMResponse: + # Snapshot the conversation as seen on this call. + self.calls.append(list(messages)) + return self._responses[len(self.calls) - 1] + + +def _registry() -> ToolRegistry: + registry = ToolRegistry() + + @registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='user id')], + ) + def lookup_user(id: int) -> dict: + return {'id': id, 'name': 'Ada'} + + return registry + + +def test_loop_returns_immediately_without_tool_calls() -> None: + provider = _ScriptedProvider([LLMResponse(text='just an answer')]) + + result = run_tool_loop( + provider, + messages=[LLMMessage(role='user', content='hi')], + registry=_registry(), + ) + + assert result.text == 'just an answer' + assert len(provider.calls) == 1 + + +def test_loop_runs_tool_then_returns_final() -> None: + provider = _ScriptedProvider( + [ + LLMResponse( + text='let me check', + tool_calls=[ToolCall(id='call_1', name='lookup_user', arguments={'id': 7})], + stop_reason='tool_use', + ), + LLMResponse(text='User 7 is Ada.'), + ] + ) + + result = run_tool_loop( + provider, + messages=[LLMMessage(role='user', content='who is user 7?')], + registry=_registry(), + ) + + assert result.text == 'User 7 is Ada.' + assert len(provider.calls) == 2 + + # The second call must see: original user, the assistant tool-use turn, then the + # tool results — in that order. + second_call = provider.calls[1] + assert [m.role for m in second_call] == ['user', 'assistant', 'tool'] + + assistant_msg = second_call[1] + assert assistant_msg.content == 'let me check' + assert list(assistant_msg.tool_calls) == [ToolCall(id='call_1', name='lookup_user', arguments={'id': 7})] + + tool_msg = second_call[2] + assert len(tool_msg.tool_results) == 1 + tool_result = tool_msg.tool_results[0] + assert tool_result.tool_call_id == 'call_1' + assert tool_result.is_error is False + assert tool_result.content == '{"id": 7, "name": "Ada"}' + + +def test_loop_does_not_mutate_input_messages() -> None: + provider = _ScriptedProvider( + [ + LLMResponse(tool_calls=[ToolCall(id='c1', name='lookup_user', arguments={'id': 1})], text=''), + LLMResponse(text='done'), + ] + ) + messages = [LLMMessage(role='user', content='go')] + + run_tool_loop(provider, messages=messages, registry=_registry()) + + assert len(messages) == 1 + + +def test_loop_raises_when_limit_exceeded() -> None: + # Always asks for a tool, never converges. + looping = LLMResponse(tool_calls=[ToolCall(id='c', name='lookup_user', arguments={'id': 1})], text='') + provider = _ScriptedProvider([looping, looping, looping]) + + try: + run_tool_loop( + provider, + messages=[LLMMessage(role='user', content='go')], + registry=_registry(), + max_iterations=3, + ) + except ToolLoopLimitExceeded as exc: + assert exc.max_iterations == 3 + else: + raise AssertionError('expected ToolLoopLimitExceeded') + + assert len(provider.calls) == 3 + + +def test_loop_rejects_invalid_max_iterations() -> None: + provider = _ScriptedProvider([LLMResponse(text='x')]) + try: + run_tool_loop( + provider, + messages=[LLMMessage(role='user', content='go')], + registry=_registry(), + max_iterations=0, + ) + except ValueError: + pass + else: + raise AssertionError('expected ValueError for max_iterations=0') diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py b/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py new file mode 100644 index 00000000..9426e31d --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py @@ -0,0 +1,141 @@ +"""Unit tests for the vendor-neutral tool declaration/registry/dispatch layer.""" + +from osprey.worker.lib.llm import ( + Tool, + ToolCall, + ToolDefinition, + ToolParameter, + ToolRegistry, + build_input_schema, +) + + +def test_build_input_schema_required_optional_enum_default() -> None: + schema = build_input_schema( + [ + ToolParameter(name='query', type='string', description='the query'), + ToolParameter(name='limit', type='integer', description='max results', required=False, default=5), + ToolParameter( + name='order', + type='string', + description='sort order', + required=False, + enum=['asc', 'desc'], + ), + ] + ) + + assert schema == { + 'type': 'object', + 'properties': { + 'query': {'type': 'string', 'description': 'the query'}, + 'limit': {'type': 'integer', 'description': 'max results', 'default': 5}, + 'order': {'type': 'string', 'description': 'sort order', 'enum': ['asc', 'desc']}, + }, + 'required': ['query'], + } + + +def test_build_input_schema_omits_required_when_none() -> None: + schema = build_input_schema([ToolParameter(name='x', type='number', description='x', required=False)]) + assert 'required' not in schema + + +def test_decorator_registers_and_returns_callable() -> None: + registry = ToolRegistry() + + @registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='user id')], + ) + def lookup_user(id: int) -> dict: + return {'id': id, 'name': 'Ada'} + + # The wrapped function is returned unchanged and stays directly callable. + assert lookup_user(7) == {'id': 7, 'name': 'Ada'} + + tool = registry.get('lookup_user') + assert isinstance(tool, Tool) + assert [t.name for t in registry.all_tools()] == ['lookup_user'] + + +def test_definitions_produce_tool_definitions() -> None: + registry = ToolRegistry() + + @registry.tool( + name='ping', description='ping', parameters=[ToolParameter(name='msg', type='string', description='m')] + ) + def ping(msg: str) -> str: + return msg + + definitions = registry.definitions() + assert len(definitions) == 1 + definition = definitions[0] + assert isinstance(definition, ToolDefinition) + assert definition.name == 'ping' + assert definition.input_schema == { + 'type': 'object', + 'properties': {'msg': {'type': 'string', 'description': 'm'}}, + 'required': ['msg'], + } + + +def test_dispatch_success_serializes_non_string_result() -> None: + registry = ToolRegistry() + + @registry.tool(name='add', description='add', parameters=[]) + def add(a: int, b: int) -> dict: + return {'sum': a + b} + + result = registry.dispatch(ToolCall(id='c1', name='add', arguments={'a': 2, 'b': 3})) + assert result.tool_call_id == 'c1' + assert result.is_error is False + assert result.content == '{"sum": 5}' + + +def test_dispatch_passes_through_string_result() -> None: + registry = ToolRegistry() + + @registry.tool(name='echo', description='echo', parameters=[]) + def echo(text: str) -> str: + return text + + result = registry.dispatch(ToolCall(id='c1', name='echo', arguments={'text': 'hi'})) + assert result.content == 'hi' + assert result.is_error is False + + +def test_dispatch_handler_exception_is_error() -> None: + registry = ToolRegistry() + + @registry.tool(name='boom', description='boom', parameters=[]) + def boom() -> None: + raise ValueError('kaboom') + + result = registry.dispatch(ToolCall(id='c1', name='boom', arguments={})) + assert result.is_error is True + assert 'ValueError' in result.content + assert 'kaboom' in result.content + + +def test_dispatch_unknown_tool_is_error() -> None: + registry = ToolRegistry() + result = registry.dispatch(ToolCall(id='c1', name='nope', arguments={})) + assert result.is_error is True + assert 'nope' in result.content + + +def test_duplicate_registration_raises() -> None: + registry = ToolRegistry() + + @registry.tool(name='dup', description='d', parameters=[]) + def first() -> None: + return None + + try: + registry.tool(name='dup', description='d', parameters=[])(first) + except ValueError: + pass + else: + raise AssertionError('expected duplicate registration to raise') diff --git a/osprey_worker/src/osprey/worker/lib/llm/tools.py b/osprey_worker/src/osprey/worker/lib/llm/tools.py new file mode 100644 index 00000000..6c674afa --- /dev/null +++ b/osprey_worker/src/osprey/worker/lib/llm/tools.py @@ -0,0 +1,174 @@ +"""Vendor-neutral helpers for declaring and dispatching LLM tools. + +This is sugar on top of the provider interface in :mod:`osprey.worker.lib.llm.base`: +tools are declared with :class:`ToolParameter` + the :meth:`ToolRegistry.tool` +decorator, which compiles to the same :class:`~osprey.worker.lib.llm.base.ToolDefinition` +the provider already consumes. The registry also binds a handler per tool so a +caller (e.g. the tool-call loop in :mod:`osprey.worker.lib.llm.loop`) can execute a +:class:`~osprey.worker.lib.llm.base.ToolCall` and feed the result back to the model. + +Handlers are plain **synchronous** callables (Osprey's worker is gevent/sync). They +receive the tool arguments as keyword arguments and return any JSON-serialisable +value (a non-string return is serialised to JSON for the model). Dependency +injection is intentionally left to the caller — bind state with ``functools.partial`` +or a closure rather than a framework-imposed context object. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Literal, Optional, Sequence + +from osprey.worker.lib.llm.base import ToolCall, ToolDefinition, ToolResult + +# JSON Schema primitive types a tool parameter may declare. +ParameterType = Literal['string', 'integer', 'number', 'boolean', 'object', 'array'] + +ToolHandler = Callable[..., Any] + + +@dataclass +class ToolParameter: + """A single tool argument, declared in a vendor-neutral way. + + Compiles to one entry of a JSON Schema ``properties`` map (plus ``required``). + """ + + name: str + type: ParameterType + description: str + required: bool = True + enum: Optional[Sequence[Any]] = None + default: Any = None + + +def build_input_schema(parameters: Sequence[ToolParameter]) -> Dict[str, Any]: + """Compile a list of :class:`ToolParameter` into a JSON Schema object.""" + properties: Dict[str, Any] = {} + required: List[str] = [] + for parameter in parameters: + prop: Dict[str, Any] = {'type': parameter.type, 'description': parameter.description} + if parameter.enum is not None: + prop['enum'] = list(parameter.enum) + if parameter.default is not None: + prop['default'] = parameter.default + properties[parameter.name] = prop + if parameter.required: + required.append(parameter.name) + + schema: Dict[str, Any] = {'type': 'object', 'properties': properties} + if required: + schema['required'] = required + return schema + + +@dataclass +class Tool: + """A declared tool: its model-facing schema plus the handler that runs it.""" + + name: str + description: str + handler: ToolHandler + parameters: Sequence[ToolParameter] = field(default_factory=list) + + def definition(self) -> ToolDefinition: + """The provider-facing :class:`ToolDefinition` for this tool.""" + return ToolDefinition( + name=self.name, + description=self.description, + input_schema=build_input_schema(self.parameters), + ) + + +def _result_to_content(value: Any) -> str: + """Serialise a handler's return value to the string content the model receives.""" + if isinstance(value, str): + return value + return json.dumps(value, default=str) + + +class ToolRegistry: + """A collection of named tools, declared via the :meth:`tool` decorator. + + Pass :meth:`definitions` to ``provider.chat(tools=...)`` and run a returned + :class:`ToolCall` with :meth:`dispatch`. + """ + + def __init__(self) -> None: + self._tools: Dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + if tool.name in self._tools: + raise ValueError(f'a tool named {tool.name!r} is already registered') + self._tools[tool.name] = tool + + def tool( + self, + name: str, + description: str, + parameters: Optional[Sequence[ToolParameter]] = None, + ) -> Callable[[ToolHandler], ToolHandler]: + """Decorator that registers the wrapped callable as a tool. + + The function is returned unchanged, so it remains directly callable:: + + registry = ToolRegistry() + + @registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='User id')], + ) + def lookup_user(id: int) -> dict: + ... + """ + + def decorator(handler: ToolHandler) -> ToolHandler: + self.register( + Tool( + name=name, + description=description, + handler=handler, + parameters=parameters or [], + ) + ) + return handler + + return decorator + + def get(self, name: str) -> Optional[Tool]: + return self._tools.get(name) + + def all_tools(self) -> List[Tool]: + return list(self._tools.values()) + + def definitions(self) -> List[ToolDefinition]: + """Tool definitions to hand to ``provider.chat(tools=...)``.""" + return [tool.definition() for tool in self._tools.values()] + + def dispatch(self, tool_call: ToolCall) -> ToolResult: + """Execute the handler for a model-requested :class:`ToolCall`. + + Errors (unknown tool, bad arguments, handler exceptions) are returned as a + :class:`ToolResult` with ``is_error=True`` so the caller can feed them back + to the model rather than aborting the conversation. + """ + tool = self._tools.get(tool_call.name) + if tool is None: + return ToolResult( + tool_call_id=tool_call.id, + content=f'Unknown tool: {tool_call.name!r}', + is_error=True, + ) + + try: + result = tool.handler(**tool_call.arguments) + except Exception as exc: # noqa: BLE001 - surfaced to the model, not swallowed + return ToolResult( + tool_call_id=tool_call.id, + content=f'{type(exc).__name__}: {exc}', + is_error=True, + ) + + return ToolResult(tool_call_id=tool_call.id, content=_result_to_content(result)) From 2da12d959260d40128a87c86057e98809a123f37 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 19:09:20 +0000 Subject: [PATCH 7/8] Move LLM docs to their own page; trim register_plugins comment - Move the LLM provider hook + tool-calling docs out of DEVELOPMENT.md into a dedicated docs/llm.md (linked from the hook table and added to SUMMARY.md). - Trim the register_plugins note to just state an LLM provider can be registered here, dropping the rationale. --- docs/DEVELOPMENT.md | 63 ++---------------------- docs/SUMMARY.md | 1 + docs/llm.md | 65 +++++++++++++++++++++++++ example_plugins/src/register_plugins.py | 6 +-- 4 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 docs/llm.md diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 0b5e1735..26f76730 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -140,66 +140,9 @@ Implement any subset of these in your plugin's `register_plugins.py`: | `register_labels_service_or_provider` | `LabelsServiceBase \| LabelsProvider` | Single-provider (`firstresult`). | | `register_llm_provider` | `BaseLLMProvider` | Single-provider (`firstresult`). LLM API access for AI-assisted features. | -### LLM provider hook - -`register_llm_provider` lets a plugin supply the LLM API client used by AI-assisted -features such as natural-language query building. The interface lives in -`osprey.worker.lib.llm` and is vendor-neutral and **tool-calling aware**: you pass -`ToolDefinition`s in, the model may return `ToolCall`s, and you feed `ToolResult`s -back on the next `chat()` call. - -```python -from osprey.worker.lib.config import Config -from osprey.worker.lib.llm.base import BaseLLMProvider - -@hookimpl_osprey -def register_llm_provider(config: Config) -> BaseLLMProvider: - return MyLLMProvider(config) -``` - -Only one provider may be registered (`firstresult=True`). Retrieve it with -`bootstrap_llm_provider(config)` from `osprey.worker.adaptor.plugin_manager`, which -returns `None` when no plugin registers one — so callers should null-check. - -A direct Anthropic implementation is provided as a reference in -`example_plugins/src/llm/anthropic_provider.py`, including the request/response and -`tool_use` translation. The `anthropic` SDK is a dependency of `example_plugins` -(installed by `uv sync`); set `ANTHROPIC_API_KEY` (or the -`OSPREY_LLM_ANTHROPIC_API_KEY` config key) to use it. The example plugins do **not** -register it by default — add your own `register_llm_provider` hookimpl to enable it. - -#### Declaring tools and running a tool loop - -`osprey.worker.lib.llm` includes an optional, vendor-neutral tool-calling layer. -Declare tools with the `@tool` decorator on a `ToolRegistry` (it compiles -`ToolParameter`s into the `ToolDefinition` JSON Schema the provider consumes), then -let `run_tool_loop` drive the call/dispatch/feed-back exchange until the model -returns a final answer. Handlers are plain synchronous callables. - -```python -from osprey.worker.lib.llm import ToolParameter, ToolRegistry, run_tool_loop, LLMMessage - -registry = ToolRegistry() - -@registry.tool( - name='lookup_user', - description='Look up a user by id.', - parameters=[ToolParameter(name='id', type='integer', description='User id')], -) -def lookup_user(id: int) -> dict: - return {'id': id, 'name': '...'} - -response = run_tool_loop( - provider, # any BaseLLMProvider - messages=[LLMMessage(role='user', content='Who is user 7?')], - registry=registry, -) -``` - -`registry.dispatch(tool_call)` runs a single tool and captures errors as a -`ToolResult` with `is_error=True` (fed back to the model rather than aborting). -`run_tool_loop` raises `ToolLoopLimitExceeded` if the model keeps requesting tools -past `max_iterations`. +The `register_llm_provider` hook and the vendor-neutral tool-calling helpers +(`@tool`, `ToolRegistry`, `run_tool_loop`) have their own page: +[LLM provider & tool calling](llm.md). ## Rules diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 5768e342..78d3936f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -10,6 +10,7 @@ - [Release Process](development/releases.md) - [User Interface](UI.md) - [Writing Rules](rules.md) +- [LLM Provider & Tool Calling](llm.md) - [User Research & Personas](user_personas.md) --- diff --git a/docs/llm.md b/docs/llm.md new file mode 100644 index 00000000..7d37a0e1 --- /dev/null +++ b/docs/llm.md @@ -0,0 +1,65 @@ +# LLM Provider & Tool Calling + +Osprey exposes an optional, vendor-neutral interface for LLM API access, used by +AI-assisted features such as natural-language query building. It lives in +`osprey.worker.lib.llm` and is **tool-calling aware**: you pass `ToolDefinition`s +in, the model may return `ToolCall`s, and you feed `ToolResult`s back on the next +`chat()` call. + +## The `register_llm_provider` hook + +A plugin supplies the LLM API client by implementing the `register_llm_provider` +hook: + +```python +from osprey.worker.lib.config import Config +from osprey.worker.lib.llm.base import BaseLLMProvider + +@hookimpl_osprey +def register_llm_provider(config: Config) -> BaseLLMProvider: + return MyLLMProvider(config) +``` + +Only one provider may be registered (`firstresult=True`). Retrieve it with +`bootstrap_llm_provider(config)` from `osprey.worker.adaptor.plugin_manager`, which +returns `None` when no plugin registers one — so callers should null-check. + +A direct Anthropic implementation is provided as a reference in +`example_plugins/src/llm/anthropic_provider.py`, including the request/response and +`tool_use` translation. The `anthropic` SDK is a dependency of `example_plugins` +(installed by `uv sync`); set `ANTHROPIC_API_KEY` (or the +`OSPREY_LLM_ANTHROPIC_API_KEY` config key) to use it. The example plugins do **not** +register it by default — add your own `register_llm_provider` hookimpl to enable it. + +## Declaring tools and running a tool loop + +`osprey.worker.lib.llm` includes an optional, vendor-neutral tool-calling layer. +Declare tools with the `@tool` decorator on a `ToolRegistry` (it compiles +`ToolParameter`s into the `ToolDefinition` JSON Schema the provider consumes), then +let `run_tool_loop` drive the call/dispatch/feed-back exchange until the model +returns a final answer. Handlers are plain synchronous callables. + +```python +from osprey.worker.lib.llm import ToolParameter, ToolRegistry, run_tool_loop, LLMMessage + +registry = ToolRegistry() + +@registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='User id')], +) +def lookup_user(id: int) -> dict: + return {'id': id, 'name': '...'} + +response = run_tool_loop( + provider, # any BaseLLMProvider + messages=[LLMMessage(role='user', content='Who is user 7?')], + registry=registry, +) +``` + +`registry.dispatch(tool_call)` runs a single tool and captures errors as a +`ToolResult` with `is_error=True` (fed back to the model rather than aborting). +`run_tool_loop` raises `ToolLoopLimitExceeded` if the model keeps requesting tools +past `max_iterations`. diff --git a/example_plugins/src/register_plugins.py b/example_plugins/src/register_plugins.py index 0d56fb21..02584051 100644 --- a/example_plugins/src/register_plugins.py +++ b/example_plugins/src/register_plugins.py @@ -26,7 +26,5 @@ def register_labels_service_or_provider(config: Config) -> LabelsServiceBase: return PostgresLabelsService() -# NOTE: the `register_llm_provider` hook is intentionally NOT implemented here. -# An LLM provider is optional, and we don't want one registered by default. See -# `llm.anthropic_provider.AnthropicLLMProvider` for a reference implementation; a -# deployment that wants it can add its own `register_llm_provider` hookimpl. +# An LLM provider can be registered here with a `register_llm_provider` hookimpl; +# see `llm.anthropic_provider.AnthropicLLMProvider` for a reference implementation. From 824883c0df566c77f8b445efd912ddfd9dce8251 Mon Sep 17 00:00:00 2001 From: hailey Date: Thu, 4 Jun 2026 19:38:32 +0000 Subject: [PATCH 8/8] Address code-review findings (tool loop + dispatch + deps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loop.py: don't dispatch tools on the final, limit-tripping iteration — side effects whose results can never be sent back to the model are no longer run before raising ToolLoopLimitExceeded. - tools.py: serialize the handler result inside dispatch's try/except so a non-serialisable/circular return becomes is_error instead of crashing the loop; document that error text reaches the model (no secrets) and the default=None sentinel. - typing-extensions: pin exact ==4.15.0 (matches repo convention + uv.lock) instead of an unbounded floor. - anthropic_provider: correct stale cache-ttl comment (1h TTL is GA, no beta header needed). - Tests: loop does not dispatch on the final iteration; dispatch captures an unserialisable result as is_error. --- example_plugins/src/llm/anthropic_provider.py | 6 ++-- .../src/osprey/worker/lib/llm/loop.py | 8 ++++- .../osprey/worker/lib/llm/tests/test_loop.py | 35 +++++++++++++++++++ .../osprey/worker/lib/llm/tests/test_tools.py | 14 ++++++++ .../src/osprey/worker/lib/llm/tools.py | 17 ++++++--- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 74 insertions(+), 10 deletions(-) diff --git a/example_plugins/src/llm/anthropic_provider.py b/example_plugins/src/llm/anthropic_provider.py index 907c9ba1..c584a007 100644 --- a/example_plugins/src/llm/anthropic_provider.py +++ b/example_plugins/src/llm/anthropic_provider.py @@ -140,9 +140,9 @@ def _build_system( def _cache_control_dict(cache_control: CacheControl) -> Dict[str, Any]: result: Dict[str, Any] = {'type': 'ephemeral'} if cache_control.ttl is not None: - # A non-default ttl (e.g. '1h') requires the Anthropic extended-cache-ttl - # beta header on the client, or the API rejects the request; the default - # 5m ephemeral cache needs no header. + # Opt into a non-default cache duration (e.g. '1h'); the default 5m + # ephemeral cache needs no ttl field. The 1h TTL is generally available + # on the Claude API — set via the ttl field, no beta header required. result['ttl'] = cache_control.ttl return result diff --git a/osprey_worker/src/osprey/worker/lib/llm/loop.py b/osprey_worker/src/osprey/worker/lib/llm/loop.py index 7b214809..a1d84e93 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/loop.py +++ b/osprey_worker/src/osprey/worker/lib/llm/loop.py @@ -60,11 +60,17 @@ def run_tool_loop( conversation: List[LLMMessage] = list(messages) tools = registry.definitions() - for _ in range(max_iterations): + for iteration in range(max_iterations): response = provider.chat(messages=conversation, system=system, tools=tools, **chat_params) if not response.tool_calls: return response + # The model still wants tools but this was our last allowed call. Don't run + # the tools (they may have side effects) when their results could never be + # sent back to the model — just give up. + if iteration == max_iterations - 1: + break + # Echo the assistant's tool-use turn, then feed back the results so the next # call sees the full exchange (providers require tool results to follow the # matching tool-use request). diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py b/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py index 759715b7..cd8a3b30 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py +++ b/osprey_worker/src/osprey/worker/lib/llm/tests/test_loop.py @@ -137,6 +137,41 @@ def test_loop_raises_when_limit_exceeded() -> None: assert len(provider.calls) == 3 +def test_loop_does_not_dispatch_tools_on_the_final_iteration() -> None: + # A registry whose tool records each invocation, so we can prove side effects + # don't run on the limit-tripping call. + dispatched: List[int] = [] + registry = ToolRegistry() + + @registry.tool( + name='lookup_user', + description='Look up a user by id.', + parameters=[ToolParameter(name='id', type='integer', description='user id')], + ) + def lookup_user(id: int) -> dict: + dispatched.append(id) + return {'id': id} + + looping = LLMResponse(tool_calls=[ToolCall(id='c', name='lookup_user', arguments={'id': 1})], text='') + provider = _ScriptedProvider([looping, looping]) + + try: + run_tool_loop( + provider, + messages=[LLMMessage(role='user', content='go')], + registry=registry, + max_iterations=2, + ) + except ToolLoopLimitExceeded: + pass + else: + raise AssertionError('expected ToolLoopLimitExceeded') + + # Two chat calls were made, but the tool ran only on the first (non-final) one. + assert len(provider.calls) == 2 + assert dispatched == [1] + + def test_loop_rejects_invalid_max_iterations() -> None: provider = _ScriptedProvider([LLMResponse(text='x')]) try: diff --git a/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py b/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py index 9426e31d..3cd41b36 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py +++ b/osprey_worker/src/osprey/worker/lib/llm/tests/test_tools.py @@ -126,6 +126,20 @@ def test_dispatch_unknown_tool_is_error() -> None: assert 'nope' in result.content +def test_dispatch_unserializable_result_is_error() -> None: + registry = ToolRegistry() + + @registry.tool(name='cyclic', description='returns a circular reference', parameters=[]) + def cyclic() -> dict: + d: dict = {} + d['self'] = d + return d + + # A non-serialisable return is captured as an error rather than propagating. + result = registry.dispatch(ToolCall(id='c1', name='cyclic', arguments={})) + assert result.is_error is True + + def test_duplicate_registration_raises() -> None: registry = ToolRegistry() diff --git a/osprey_worker/src/osprey/worker/lib/llm/tools.py b/osprey_worker/src/osprey/worker/lib/llm/tools.py index 6c674afa..794835be 100644 --- a/osprey_worker/src/osprey/worker/lib/llm/tools.py +++ b/osprey_worker/src/osprey/worker/lib/llm/tools.py @@ -40,6 +40,8 @@ class ToolParameter: description: str required: bool = True enum: Optional[Sequence[Any]] = None + # ``None`` is the "no default" sentinel; a JSON Schema ``default`` is only + # emitted for non-``None`` values. default: Any = None @@ -150,9 +152,13 @@ def definitions(self) -> List[ToolDefinition]: def dispatch(self, tool_call: ToolCall) -> ToolResult: """Execute the handler for a model-requested :class:`ToolCall`. - Errors (unknown tool, bad arguments, handler exceptions) are returned as a - :class:`ToolResult` with ``is_error=True`` so the caller can feed them back - to the model rather than aborting the conversation. + Errors (unknown tool, bad arguments, handler exceptions, or a result that + can't be serialised) are returned as a :class:`ToolResult` with + ``is_error=True`` so the caller can feed them back to the model rather than + aborting the conversation. + + Note: the error text is sent to the model, so handlers should not embed + secrets (credentials, connection strings, internal ids) in exceptions. """ tool = self._tools.get(tool_call.name) if tool is None: @@ -163,7 +169,10 @@ def dispatch(self, tool_call: ToolCall) -> ToolResult: ) try: + # Serialise inside the try so a non-serialisable result (e.g. a circular + # reference) becomes an error result rather than propagating to the caller. result = tool.handler(**tool_call.arguments) + content = _result_to_content(result) except Exception as exc: # noqa: BLE001 - surfaced to the model, not swallowed return ToolResult( tool_call_id=tool_call.id, @@ -171,4 +180,4 @@ def dispatch(self, tool_call: ToolCall) -> ToolResult: is_error=True, ) - return ToolResult(tool_call_id=tool_call.id, content=_result_to_content(result)) + return ToolResult(tool_call_id=tool_call.id, content=content) diff --git a/pyproject.toml b/pyproject.toml index f03c23c8..52bdeb9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ common = [ "tink==1.9.0", "tld==0.12.7", "traitlets==5.14.3", - "typing-extensions>=4.14.0", + "typing-extensions==4.15.0", "typing-inspect==0.9.0", "unidecode==1.3.8", "werkzeug==1.0.1", diff --git a/uv.lock b/uv.lock index d1daa2ee..fe18e485 100644 --- a/uv.lock +++ b/uv.lock @@ -119,7 +119,7 @@ common = [ { name = "types-six", specifier = "==1.17.0.20250515" }, { name = "types-urllib3", specifier = "==1.26.25.14" }, { name = "types-werkzeug", specifier = "==1.0.9" }, - { name = "typing-extensions", specifier = ">=4.14.0" }, + { name = "typing-extensions", specifier = "==4.15.0" }, { name = "typing-inspect", specifier = "==0.9.0" }, { name = "unidecode", specifier = "==1.3.8" }, { name = "werkzeug", specifier = "==1.0.1" },