diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 8a80dd519..67246b344 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -37,7 +37,7 @@ jobs: run: uv run python3 -m pytest --runslow . - name: Check Python Types - run: uvx ty check + run: uv run ty check - name: Build Core run: uv build diff --git a/.github/workflows/format_and_lint.yml b/.github/workflows/format_and_lint.yml index e1d6aabc8..c81156cf8 100644 --- a/.github/workflows/format_and_lint.yml +++ b/.github/workflows/format_and_lint.yml @@ -40,12 +40,12 @@ jobs: - name: Lint with ruff run: | - uvx ruff check + uv run ruff check - name: Format with ruff run: | - uvx ruff format --check . + uv run ruff format --check . - name: Typecheck with ty run: | - uvx ty check + uv run ty check diff --git a/checks.sh b/checks.sh index bb53d64cf..f753b19e2 100755 --- a/checks.sh +++ b/checks.sh @@ -31,14 +31,14 @@ echo $PWD headerStart="\n\033[4;34m=== " headerEnd=" ===\033[0m\n" -echo "${headerStart}Checking Python: uvx ruff check ${headerEnd}" -uvx ruff check +echo "${headerStart}Checking Python: uv run ruff check ${headerEnd}" +uv run ruff check -echo "${headerStart}Checking Python: uvx ruff format --check ${headerEnd}" -uvx ruff format --check . +echo "${headerStart}Checking Python: uv run ruff format --check ${headerEnd}" +uv run ruff format --check . -echo "${headerStart}Checking Python Types: uvx ty check${headerEnd}" -uvx ty check +echo "${headerStart}Checking Python Types: uv run ty check${headerEnd}" +uv run ty check echo "${headerStart}Checking for Misspellings${headerEnd}" if command -v misspell >/dev/null 2>&1; then diff --git a/hooks_mcp.yaml b/hooks_mcp.yaml index ff383c2dc..8374e6997 100644 --- a/hooks_mcp.yaml +++ b/hooks_mcp.yaml @@ -12,23 +12,23 @@ actions: - name: "lint_python" description: "Lint the python source code, checking for errors and warnings" - command: "uvx ruff check" + command: "uv run ruff check" - name: "lint_fix_python" description: "Lint the pythong source code, fixing errors and warnings which it can fix. Not all errors can be fixed automatically." - command: "uvx ruff check --fix" + command: "uv run ruff check --fix" - name: "check_format_python" description: "Check if the python source code is formatted correctly" - command: "uvx ruff format --check ." + command: "uv run ruff format --check ." - name: "format_python" description: "Format the python source code" - command: "uvx ruff format ." + command: "uv run ruff format ." - name: "typecheck_python" description: "Typecheck the source code" - command: "uvx ty check" + command: "uv run ty check" - name: "test_file_python" description: "Run tests in a specific python file or directory" diff --git a/libs/core/kiln_ai/adapters/litellm_utils/__init__.py b/libs/core/kiln_ai/adapters/litellm_utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/core/kiln_ai/adapters/litellm_utils/litellm_streaming.py b/libs/core/kiln_ai/adapters/litellm_utils/litellm_streaming.py new file mode 100644 index 000000000..68b29dd62 --- /dev/null +++ b/libs/core/kiln_ai/adapters/litellm_utils/litellm_streaming.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Any, AsyncIterator, Optional, Union + +import litellm +from litellm.types.utils import ( + ModelResponse, + ModelResponseStream, + TextCompletionResponse, +) + + +class StreamingCompletion: + """ + Async iterable wrapper around ``litellm.acompletion`` with streaming. + + Yields ``ModelResponseStream`` chunks as they arrive. After iteration + completes, the assembled ``ModelResponse`` is available via the + ``.response`` property. + + Usage:: + + stream = StreamingCompletion(model=..., messages=...) + async for chunk in stream: + # handle chunk however you like (print, log, send over WS, …) + pass + final = stream.response # fully assembled ModelResponse + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs = dict(kwargs) + kwargs.pop("stream", None) + self._args = args + self._kwargs = kwargs + self._response: Optional[Union[ModelResponse, TextCompletionResponse]] = None + self._iterated: bool = False + + @property + def response(self) -> Optional[Union[ModelResponse, TextCompletionResponse]]: + """The final assembled response. Only available after iteration.""" + if not self._iterated: + raise RuntimeError( + "StreamingCompletion has not been iterated yet. " + "Use 'async for chunk in stream:' before accessing .response" + ) + return self._response + + async def __aiter__(self) -> AsyncIterator[ModelResponseStream]: + self._response = None + self._iterated = False + + chunks: list[ModelResponseStream] = [] + stream = await litellm.acompletion(*self._args, stream=True, **self._kwargs) + + async for chunk in stream: + chunks.append(chunk) + yield chunk + + self._response = litellm.stream_chunk_builder(chunks) + self._iterated = True diff --git a/libs/core/kiln_ai/adapters/litellm_utils/test_litellm_streaming.py b/libs/core/kiln_ai/adapters/litellm_utils/test_litellm_streaming.py new file mode 100644 index 000000000..e35a51982 --- /dev/null +++ b/libs/core/kiln_ai/adapters/litellm_utils/test_litellm_streaming.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any, List +from unittest.mock import MagicMock, patch + +import pytest + +from kiln_ai.adapters.litellm_utils.litellm_streaming import StreamingCompletion + + +def _make_chunk(content: str | None = None, finish_reason: str | None = None) -> Any: + """Build a minimal chunk object matching litellm's streaming shape.""" + delta = SimpleNamespace(content=content, role="assistant") + choice = SimpleNamespace(delta=delta, finish_reason=finish_reason, index=0) + return SimpleNamespace(choices=[choice], id="chatcmpl-test", model="test-model") + + +async def _async_iter(items: List[Any]): + """Turn a plain list into an async iterator.""" + for item in items: + yield item + + +@pytest.fixture +def mock_acompletion(): + with patch("litellm.acompletion") as mock: + yield mock + + +@pytest.fixture +def mock_chunk_builder(): + with patch("litellm.stream_chunk_builder") as mock: + yield mock + + +class TestStreamingCompletion: + async def test_yields_all_chunks(self, mock_acompletion, mock_chunk_builder): + chunks = [_make_chunk("Hello"), _make_chunk(" world"), _make_chunk("!")] + mock_acompletion.return_value = _async_iter(chunks) + mock_chunk_builder.return_value = MagicMock(name="final_response") + + stream = StreamingCompletion(model="test", messages=[]) + received = [chunk async for chunk in stream] + + assert received == chunks + + async def test_response_available_after_iteration( + self, mock_acompletion, mock_chunk_builder + ): + chunks = [_make_chunk("hi")] + mock_acompletion.return_value = _async_iter(chunks) + sentinel = MagicMock(name="final_response") + mock_chunk_builder.return_value = sentinel + + stream = StreamingCompletion(model="test", messages=[]) + async for _ in stream: + pass + + assert stream.response is sentinel + + async def test_response_raises_before_iteration(self): + stream = StreamingCompletion(model="test", messages=[]) + with pytest.raises(RuntimeError, match="not been iterated"): + _ = stream.response + + async def test_stream_kwarg_is_stripped(self, mock_acompletion, mock_chunk_builder): + mock_acompletion.return_value = _async_iter([]) + mock_chunk_builder.return_value = None + + stream = StreamingCompletion(model="test", messages=[], stream=False) + async for _ in stream: + pass + + _, call_kwargs = mock_acompletion.call_args + assert call_kwargs["stream"] is True + + async def test_passes_args_and_kwargs_through( + self, mock_acompletion, mock_chunk_builder + ): + mock_acompletion.return_value = _async_iter([]) + mock_chunk_builder.return_value = None + + stream = StreamingCompletion( + model="gpt-4", messages=[{"role": "user", "content": "hi"}], temperature=0.5 + ) + async for _ in stream: + pass + + _, call_kwargs = mock_acompletion.call_args + assert call_kwargs["model"] == "gpt-4" + assert call_kwargs["messages"] == [{"role": "user", "content": "hi"}] + assert call_kwargs["temperature"] == 0.5 + assert call_kwargs["stream"] is True + + async def test_chunks_passed_to_builder(self, mock_acompletion, mock_chunk_builder): + chunks = [_make_chunk("a"), _make_chunk("b")] + mock_acompletion.return_value = _async_iter(chunks) + mock_chunk_builder.return_value = MagicMock() + + stream = StreamingCompletion(model="test", messages=[]) + async for _ in stream: + pass + + mock_chunk_builder.assert_called_once_with(chunks) + + async def test_re_iteration_resets_state( + self, mock_acompletion, mock_chunk_builder + ): + first_chunks = [_make_chunk("first")] + second_chunks = [_make_chunk("second")] + first_response = MagicMock(name="first_response") + second_response = MagicMock(name="second_response") + + mock_acompletion.side_effect = [ + _async_iter(first_chunks), + _async_iter(second_chunks), + ] + mock_chunk_builder.side_effect = [first_response, second_response] + + stream = StreamingCompletion(model="test", messages=[]) + + async for _ in stream: + pass + assert stream.response is first_response + + async for _ in stream: + pass + assert stream.response is second_response + + async def test_empty_stream(self, mock_acompletion, mock_chunk_builder): + mock_acompletion.return_value = _async_iter([]) + mock_chunk_builder.return_value = None + + stream = StreamingCompletion(model="test", messages=[]) + received = [chunk async for chunk in stream] + + assert received == [] + assert stream.response is None diff --git a/libs/core/kiln_ai/adapters/ml_model_list.py b/libs/core/kiln_ai/adapters/ml_model_list.py index eb46cd5b1..ed647cfd7 100644 --- a/libs/core/kiln_ai/adapters/ml_model_list.py +++ b/libs/core/kiln_ai/adapters/ml_model_list.py @@ -117,16 +117,21 @@ class ModelName(str, Enum): gemma_3n_4b = "gemma_3n_4b" claude_3_5_haiku = "claude_3_5_haiku" claude_4_5_haiku = "claude_4_5_haiku" + claude_4_5_haiku_thinking = "claude_4_5_haiku_thinking" claude_3_5_sonnet = "claude_3_5_sonnet" claude_3_7_sonnet = "claude_3_7_sonnet" claude_3_7_sonnet_thinking = "claude_3_7_sonnet_thinking" claude_sonnet_4 = "claude_sonnet_4" claude_sonnet_4_6 = "claude_sonnet_4_6" + claude_sonnet_4_6_thinking = "claude_sonnet_4_6_thinking" claude_sonnet_4_5 = "claude_sonnet_4_5" + claude_sonnet_4_5_thinking = "claude_sonnet_4_5_thinking" claude_opus_4 = "claude_opus_4" claude_opus_4_1 = "claude_opus_4_1" claude_opus_4_5 = "claude_opus_4_5" + claude_opus_4_5_thinking = "claude_opus_4_5_thinking" claude_opus_4_6 = "claude_opus_4_6" + claude_opus_4_6_thinking = "claude_opus_4_6_thinking" gemini_1_5_flash = "gemini_1_5_flash" gemini_1_5_flash_8b = "gemini_1_5_flash_8b" gemini_1_5_pro = "gemini_1_5_pro" @@ -1296,6 +1301,29 @@ class KilnModel(BaseModel): ), ], ), + # Claude 4.5 Haiku Thinking + KilnModel( + family=ModelFamily.claude, + name=ModelName.claude_4_5_haiku_thinking, + friendly_name="Claude 4.5 Haiku Thinking", + featured_rank=11, + editorial_notes="Claude on a budget. 20% the cost of Claude Opus. Great for easier tasks.", + providers=[ + KilnModelProvider( + name=ModelProviderName.openrouter, + model_id="anthropic/claude-haiku-4.5", + structured_output_mode=StructuredOutputMode.function_calling, + thinking_level="medium", + ), + KilnModelProvider( + name=ModelProviderName.anthropic, + model_id="claude-haiku-4-5-20251001", + structured_output_mode=StructuredOutputMode.json_schema, + temp_top_p_exclusive=True, + thinking_level="medium", + ), + ], + ), # Claude 3.5 Haiku KilnModel( family=ModelFamily.claude, @@ -1363,6 +1391,52 @@ class KilnModel(BaseModel): ), ], ), + # Claude Sonnet 4.6 Thinking + KilnModel( + family=ModelFamily.claude, + name=ModelName.claude_sonnet_4_6_thinking, + friendly_name="Claude 4.6 Sonnet Thinking", + providers=[ + KilnModelProvider( + name=ModelProviderName.openrouter, + model_id="anthropic/claude-sonnet-4.6", + structured_output_mode=StructuredOutputMode.function_calling, + suggested_for_data_gen=True, + suggested_for_evals=True, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.PDF, + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + multimodal_requires_pdf_as_image=True, + thinking_level="medium", + ), + KilnModelProvider( + name=ModelProviderName.anthropic, + model_id="claude-sonnet-4-6", + structured_output_mode=StructuredOutputMode.json_schema, + temp_top_p_exclusive=True, + suggested_for_data_gen=True, + suggested_for_evals=True, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.PDF, + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + thinking_level="medium", + ), + ], + ), # Claude Sonnet 4.5 KilnModel( family=ModelFamily.claude, @@ -1382,6 +1456,27 @@ class KilnModel(BaseModel): ), ], ), + # Claude Sonnet 4.5 Thinking + KilnModel( + family=ModelFamily.claude, + name=ModelName.claude_sonnet_4_5_thinking, + friendly_name="Claude 4.5 Sonnet Thinking", + providers=[ + KilnModelProvider( + name=ModelProviderName.openrouter, + model_id="anthropic/claude-4.5-sonnet", + structured_output_mode=StructuredOutputMode.function_calling, + thinking_level="medium", + ), + KilnModelProvider( + name=ModelProviderName.anthropic, + model_id="claude-sonnet-4-5-20250929", + structured_output_mode=StructuredOutputMode.json_schema, + temp_top_p_exclusive=True, + thinking_level="medium", + ), + ], + ), # Claude Sonnet 4 KilnModel( family=ModelFamily.claude, @@ -1508,6 +1603,52 @@ class KilnModel(BaseModel): ), ], ), + # Claude Opus 4.6 Thinking + KilnModel( + family=ModelFamily.claude, + name=ModelName.claude_opus_4_6_thinking, + friendly_name="Claude Opus 4.6 Thinking", + featured_rank=2, + editorial_notes="Anthropic's best Claude model, with thinking. Expensive, but often the best.", + providers=[ + KilnModelProvider( + name=ModelProviderName.openrouter, + model_id="anthropic/claude-opus-4.6", + structured_output_mode=StructuredOutputMode.json_schema, + suggested_for_evals=True, + suggested_for_data_gen=True, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + thinking_level="medium", + ), + KilnModelProvider( + name=ModelProviderName.anthropic, + model_id="claude-opus-4-6", + structured_output_mode=StructuredOutputMode.json_schema, + temp_top_p_exclusive=True, + suggested_for_evals=True, + suggested_for_data_gen=True, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.PDF, + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + thinking_level="medium", + ), + ], + ), # Claude Opus 4.5 KilnModel( family=ModelFamily.claude, @@ -1547,6 +1688,47 @@ class KilnModel(BaseModel): ), ], ), + # Claude Opus 4.5 Thinking + KilnModel( + family=ModelFamily.claude, + name=ModelName.claude_opus_4_5_thinking, + friendly_name="Claude Opus 4.5 Thinking", + providers=[ + KilnModelProvider( + name=ModelProviderName.openrouter, + model_id="anthropic/claude-opus-4.5", + structured_output_mode=StructuredOutputMode.json_schema, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.PDF, + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + thinking_level="medium", + ), + KilnModelProvider( + name=ModelProviderName.anthropic, + model_id="claude-opus-4-5-20251101", + structured_output_mode=StructuredOutputMode.json_schema, + temp_top_p_exclusive=True, + supports_doc_extraction=True, + supports_vision=True, + multimodal_capable=True, + multimodal_mime_types=[ + KilnMimeType.PDF, + KilnMimeType.TXT, + KilnMimeType.MD, + KilnMimeType.JPG, + KilnMimeType.PNG, + ], + thinking_level="medium", + ), + ], + ), # Claude Opus 4.1 KilnModel( family=ModelFamily.claude, diff --git a/libs/core/kiln_ai/adapters/model_adapters/base_adapter.py b/libs/core/kiln_ai/adapters/model_adapters/base_adapter.py index 134ebc7d3..210eeee45 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/base_adapter.py +++ b/libs/core/kiln_ai/adapters/model_adapters/base_adapter.py @@ -1,8 +1,11 @@ import json from abc import ABCMeta, abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Dict, Tuple +from litellm.types.utils import ModelResponseStream + from kiln_ai.adapters.chat.chat_formatter import ChatFormatter, get_chat_formatter from kiln_ai.adapters.ml_model_list import ( KilnModelProvider, @@ -45,6 +48,8 @@ from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error from kiln_ai.utils.open_ai_types import ChatCompletionMessageParam +StreamCallback = Callable[[ModelResponseStream], Awaitable[None]] + @dataclass class AdapterConfig: @@ -123,14 +128,18 @@ async def invoke( self, input: InputType, input_source: DataSource | None = None, + on_chunk: StreamCallback | None = None, ) -> TaskRun: - run_output, _ = await self.invoke_returning_run_output(input, input_source) + run_output, _ = await self.invoke_returning_run_output( + input, input_source, on_chunk=on_chunk + ) return run_output async def _run_returning_run_output( self, input: InputType, input_source: DataSource | None = None, + on_chunk: StreamCallback | None = None, ) -> Tuple[TaskRun, RunOutput]: # validate input, allowing arrays if self.input_schema is not None: @@ -149,7 +158,7 @@ async def _run_returning_run_output( formatted_input = formatter.format_input(input) # Run - run_output, usage = await self._run(formatted_input) + run_output, usage = await self._run(formatted_input, on_chunk=on_chunk) # Parse provider = self.model_provider() @@ -220,6 +229,7 @@ async def invoke_returning_run_output( self, input: InputType, input_source: DataSource | None = None, + on_chunk: StreamCallback | None = None, ) -> Tuple[TaskRun, RunOutput]: # Determine if this is the root agent (no existing run context) is_root_agent = get_agent_run_id() is None @@ -229,7 +239,9 @@ async def invoke_returning_run_output( set_agent_run_id(run_id) try: - return await self._run_returning_run_output(input, input_source) + return await self._run_returning_run_output( + input, input_source, on_chunk=on_chunk + ) finally: if is_root_agent: try: @@ -247,7 +259,9 @@ def adapter_name(self) -> str: pass @abstractmethod - async def _run(self, input: InputType) -> Tuple[RunOutput, Usage | None]: + async def _run( + self, input: InputType, on_chunk: StreamCallback | None = None + ) -> Tuple[RunOutput, Usage | None]: pass def build_prompt(self) -> str: diff --git a/libs/core/kiln_ai/adapters/model_adapters/litellm_adapter.py b/libs/core/kiln_ai/adapters/model_adapters/litellm_adapter.py index 41a8e64c0..6e5edcc3b 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/litellm_adapter.py +++ b/libs/core/kiln_ai/adapters/model_adapters/litellm_adapter.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from typing import Any, Dict, List, Tuple, TypeAlias, Union -import litellm from litellm.types.utils import ( ChatCompletionMessageToolCall, ChoiceLogprobs, @@ -19,6 +18,7 @@ ) import kiln_ai.datamodel as datamodel +from kiln_ai.adapters.litellm_utils.litellm_streaming import StreamingCompletion from kiln_ai.adapters.ml_model_list import ( KilnModelProvider, ModelProviderName, @@ -28,6 +28,7 @@ AdapterConfig, BaseAdapter, RunOutput, + StreamCallback, Usage, ) from kiln_ai.adapters.model_adapters.litellm_config import LiteLlmConfig @@ -98,6 +99,7 @@ async def _run_model_turn( prior_messages: list[ChatCompletionMessageIncludingLiteLLM], top_logprobs: int | None, skip_response_format: bool, + on_chunk: StreamCallback | None = None, ) -> ModelTurnResult: """ Call the model for a single top level turn: from user message to agent message. @@ -121,7 +123,7 @@ async def _run_model_turn( # Make the completion call model_response, response_choice = await self.acompletion_checking_response( - **completion_kwargs + on_chunk=on_chunk, **completion_kwargs ) # count the usage @@ -184,7 +186,9 @@ async def _run_model_turn( f"Too many tool calls ({tool_calls_count}). Stopping iteration to avoid using too many tokens." ) - async def _run(self, input: InputType) -> tuple[RunOutput, Usage | None]: + async def _run( + self, input: InputType, on_chunk: StreamCallback | None = None + ) -> tuple[RunOutput, Usage | None]: usage = Usage() provider = self.model_provider() @@ -223,6 +227,7 @@ async def _run(self, input: InputType) -> tuple[RunOutput, Usage | None]: messages, self.base_adapter_config.top_logprobs if turn.final_call else None, skip_response_format, + on_chunk=on_chunk, ) usage += turn_result.usage @@ -291,9 +296,14 @@ def _extract_reasoning_to_intermediate_outputs( intermediate_outputs["reasoning"] = stripped_reasoning_content async def acompletion_checking_response( - self, **kwargs + self, on_chunk: StreamCallback | None = None, **kwargs ) -> Tuple[ModelResponse, Choices]: - response = await litellm.acompletion(**kwargs) + stream = StreamingCompletion(**kwargs) + async for chunk in stream: + if on_chunk is not None: + await on_chunk(chunk) + response = stream.response + if ( not isinstance(response, ModelResponse) or not response.choices @@ -406,6 +416,11 @@ def build_extra_body(self, provider: KilnModelProvider) -> dict[str, Any]: if provider.thinking_level is not None: extra_body["reasoning_effort"] = provider.thinking_level + # anthropic does not need allowed_openai_params, and we get an error if we pass it in + # but openrouter for example does need it or throws an error + if provider.name == ModelProviderName.openrouter: + extra_body["allowed_openai_params"] = ["reasoning_effort"] + if provider.require_openrouter_reasoning: # https://openrouter.ai/docs/use-cases/reasoning-tokens extra_body["reasoning"] = { diff --git a/libs/core/kiln_ai/adapters/model_adapters/mcp_adapter.py b/libs/core/kiln_ai/adapters/model_adapters/mcp_adapter.py index a7f1a8b6c..13d26dd48 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/mcp_adapter.py +++ b/libs/core/kiln_ai/adapters/model_adapters/mcp_adapter.py @@ -1,7 +1,11 @@ import json from typing import Tuple -from kiln_ai.adapters.model_adapters.base_adapter import AdapterConfig, BaseAdapter +from kiln_ai.adapters.model_adapters.base_adapter import ( + AdapterConfig, + BaseAdapter, + StreamCallback, +) from kiln_ai.adapters.parsers.json_parser import parse_json_string from kiln_ai.adapters.run_output import RunOutput from kiln_ai.datamodel import DataSource, Task, TaskRun, Usage @@ -42,7 +46,9 @@ def __init__( def adapter_name(self) -> str: return "mcp_adapter" - async def _run(self, input: InputType) -> Tuple[RunOutput, Usage | None]: + async def _run( + self, input: InputType, on_chunk: StreamCallback | None = None + ) -> Tuple[RunOutput, Usage | None]: run_config = self.run_config if not isinstance(run_config, McpRunConfigProperties): raise ValueError("MCPAdapter requires McpRunConfigProperties") @@ -75,14 +81,18 @@ async def invoke( self, input: InputType, input_source: DataSource | None = None, + on_chunk: StreamCallback | None = None, ) -> TaskRun: - run_output, _ = await self.invoke_returning_run_output(input, input_source) + run_output, _ = await self.invoke_returning_run_output( + input, input_source, on_chunk=on_chunk + ) return run_output async def invoke_returning_run_output( self, input: InputType, input_source: DataSource | None = None, + on_chunk: StreamCallback | None = None, ) -> Tuple[TaskRun, RunOutput]: """ Runs the task and returns both the persisted TaskRun and raw RunOutput. @@ -95,7 +105,9 @@ async def invoke_returning_run_output( set_agent_run_id(run_id) try: - return await self._run_and_validate_output(input, input_source) + return await self._run_and_validate_output( + input, input_source, on_chunk=on_chunk + ) finally: if is_root_agent: try: @@ -109,6 +121,7 @@ async def _run_and_validate_output( self, input: InputType, input_source: DataSource | None, + on_chunk: StreamCallback | None = None, ) -> Tuple[TaskRun, RunOutput]: """ Run the MCP task and validate the output. @@ -121,7 +134,7 @@ async def _run_and_validate_output( require_object=False, ) - run_output, usage = await self._run(input) + run_output, usage = await self._run(input, on_chunk=on_chunk) if self.output_schema is not None: if isinstance(run_output.output, str): diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_base_adapter.py b/libs/core/kiln_ai/adapters/model_adapters/test_base_adapter.py index d2a59a70f..da23a17db 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/test_base_adapter.py +++ b/libs/core/kiln_ai/adapters/model_adapters/test_base_adapter.py @@ -20,7 +20,7 @@ class MockAdapter(BaseAdapter): """Concrete implementation of BaseAdapter for testing""" - async def _run(self, input): + async def _run(self, input, **kwargs): return None, None def adapter_name(self) -> str: @@ -233,7 +233,7 @@ async def test_input_formatting( # Mock the _run method to capture the input captured_input = None - async def mock_run(input): + async def mock_run(input, **kwargs): nonlocal captured_input captured_input = input return RunOutput(output="test output", intermediate_outputs={}), None @@ -681,7 +681,7 @@ async def test_invoke_sets_run_context(self, adapter, clear_context): from kiln_ai.run_context import get_agent_run_id # Mock the _run method - async def mock_run(input): + async def mock_run(input, **kwargs): # Check that run ID is set during _run run_id = get_agent_run_id() assert run_id is not None @@ -721,7 +721,7 @@ async def test_invoke_clears_run_context_after(self, adapter, clear_context): from kiln_ai.run_context import get_agent_run_id # Mock the _run method - async def mock_run(input): + async def mock_run(input, **kwargs): return RunOutput(output="test output", intermediate_outputs={}), None adapter._run = mock_run @@ -759,7 +759,7 @@ async def test_invoke_clears_run_context_on_error(self, adapter, clear_context): from kiln_ai.run_context import get_agent_run_id # Mock the _run method to raise an error - async def mock_run(input): + async def mock_run(input, **kwargs): # Run ID should be set even when error occurs run_id = get_agent_run_id() assert run_id is not None @@ -796,7 +796,7 @@ async def test_sub_agent_inherits_run(self, adapter, clear_context): set_agent_run_id(parent_run_id) # Mock the _run method to check inherited run ID - async def mock_run(input): + async def mock_run(input, **kwargs): # Sub-agent should see parent's run ID run_id = get_agent_run_id() assert run_id == parent_run_id @@ -845,7 +845,7 @@ async def test_sub_agent_does_not_create_new_run(self, adapter, clear_context): run_id_during_run = None # Mock the _run method to capture run ID - async def mock_run(input): + async def mock_run(input, **kwargs): nonlocal run_id_during_run run_id_during_run = get_agent_run_id() return RunOutput(output="test output", intermediate_outputs={}), None @@ -885,7 +885,7 @@ async def test_cleanup_session_called_on_completion(self, adapter, clear_context from kiln_ai.adapters.run_output import RunOutput # Mock the _run method - async def mock_run(input): + async def mock_run(input, **kwargs): return RunOutput(output="test output", intermediate_outputs={}), None adapter._run = mock_run @@ -928,3 +928,122 @@ async def mock_run(input): assert call_args is not None run_id = call_args[0][0] if call_args[0] else call_args[1]["run_id"] assert run_id.startswith("run_") + + +class TestStreamCallback: + """Tests for the on_chunk streaming callback parameter.""" + + @pytest.fixture + def stream_adapter(self, base_task): + return MockAdapter( + task=base_task, + run_config=KilnAgentRunConfigProperties( + model_name="test_model", + model_provider_name="openai", + prompt_id="simple_prompt_builder", + structured_output_mode="json_schema", + ), + ) + + def _setup_adapter_mocks(self, adapter): + provider = MagicMock() + provider.parser = "test_parser" + provider.formatter = None + provider.reasoning_capable = False + adapter.model_provider = MagicMock(return_value=provider) + + @pytest.mark.asyncio + async def test_on_chunk_forwarded_to_run(self, stream_adapter): + """Test that on_chunk is passed through to _run.""" + received_kwargs = {} + + async def mock_run(input, **kwargs): + received_kwargs.update(kwargs) + return RunOutput(output="test output", intermediate_outputs={}), None + + stream_adapter._run = mock_run + self._setup_adapter_mocks(stream_adapter) + + callback = AsyncMock() + + parser = MagicMock() + parser.parse_output.return_value = RunOutput( + output="test output", intermediate_outputs={} + ) + + with ( + patch( + "kiln_ai.adapters.model_adapters.base_adapter.model_parser_from_id" + ) as mock_parser_factory, + patch( + "kiln_ai.adapters.model_adapters.base_adapter.request_formatter_from_id" + ), + ): + mock_parser_factory.return_value = parser + await stream_adapter.invoke_returning_run_output( + {"test": "input"}, on_chunk=callback + ) + + assert received_kwargs.get("on_chunk") is callback + + @pytest.mark.asyncio + async def test_on_chunk_none_by_default(self, stream_adapter): + """Test that on_chunk defaults to None when not provided.""" + received_kwargs = {} + + async def mock_run(input, **kwargs): + received_kwargs.update(kwargs) + return RunOutput(output="test output", intermediate_outputs={}), None + + stream_adapter._run = mock_run + self._setup_adapter_mocks(stream_adapter) + + parser = MagicMock() + parser.parse_output.return_value = RunOutput( + output="test output", intermediate_outputs={} + ) + + with ( + patch( + "kiln_ai.adapters.model_adapters.base_adapter.model_parser_from_id" + ) as mock_parser_factory, + patch( + "kiln_ai.adapters.model_adapters.base_adapter.request_formatter_from_id" + ), + ): + mock_parser_factory.return_value = parser + await stream_adapter.invoke_returning_run_output({"test": "input"}) + + assert received_kwargs.get("on_chunk") is None + + @pytest.mark.asyncio + async def test_invoke_forwards_on_chunk(self, stream_adapter): + """Test that invoke() also forwards on_chunk.""" + received_kwargs = {} + + async def mock_run(input, **kwargs): + received_kwargs.update(kwargs) + return RunOutput(output="test output", intermediate_outputs={}), None + + stream_adapter._run = mock_run + self._setup_adapter_mocks(stream_adapter) + + callback = AsyncMock() + + parser = MagicMock() + parser.parse_output.return_value = RunOutput( + output="test output", intermediate_outputs={} + ) + + with ( + patch( + "kiln_ai.adapters.model_adapters.base_adapter.model_parser_from_id" + ) as mock_parser_factory, + patch( + "kiln_ai.adapters.model_adapters.base_adapter.request_formatter_from_id" + ), + ): + mock_parser_factory.return_value = parser + await stream_adapter.invoke({"test": "input"}, on_chunk=callback) + + assert received_kwargs.get("on_chunk") is callback diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter.py b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter.py index 7d0414b99..1665b06b9 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter.py +++ b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter.py @@ -1209,7 +1209,11 @@ async def test_array_input_converted_to_json(tmp_path, config): mock_config_obj.user_id = "test_user" with ( - patch("litellm.acompletion", new=AsyncMock(return_value=mock_response)), + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock(return_value=(mock_response, mock_response.choices[0])), + ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config_obj), ): array_input = [1, 2, 3, 4, 5] @@ -1279,7 +1283,11 @@ async def test_dict_input_converted_to_json(tmp_path, config): mock_config_obj.user_id = "test_user" with ( - patch("litellm.acompletion", new=AsyncMock(return_value=mock_response)), + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock(return_value=(mock_response, mock_response.choices[0])), + ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config_obj), ): dict_input = {"x": 10, "y": 20} diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_streaming.py b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_streaming.py new file mode 100644 index 000000000..dd5e5fadd --- /dev/null +++ b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_streaming.py @@ -0,0 +1,415 @@ +import json +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Callable, Tuple +from unittest.mock import patch + +import litellm +import pytest +from litellm.types.utils import ChatCompletionDeltaToolCall + +from kiln_ai.adapters.ml_model_list import ModelProviderName, StructuredOutputMode +from kiln_ai.adapters.model_adapters.litellm_adapter import LiteLlmAdapter +from kiln_ai.adapters.model_adapters.litellm_config import LiteLlmConfig +from kiln_ai.datamodel import Project, PromptGenerators, Task +from kiln_ai.datamodel.run_config import KilnAgentRunConfigProperties, ToolsRunConfig +from kiln_ai.datamodel.tool_id import KilnBuiltInToolId + +logger = logging.getLogger(__name__) + + +class ChunkRendererAbstract(ABC): + @abstractmethod + async def render_chunk(self, chunk: litellm.ModelResponseStream): + pass + + @abstractmethod + def get_stream_text(self) -> str: + pass + + +class ChunkRenderer(ChunkRendererAbstract): + def __init__(self): + self.chunk_texts: list[str] = [] + self.current_block_type: str | None = None + + def print_and_append(self, text: str): + # replace with print if your logger is not outputting info logs + logger.info(text) + self.chunk_texts.append(text) + + def enter_block(self, block_type: str): + if self.current_block_type != block_type: + if self.current_block_type is not None: + self.print_and_append(f"\n") + + self.print_and_append(f"\n<{block_type}>\n") + self.current_block_type = block_type + + def render_reasoning(self, reasoning_content: str): + self.enter_block("reasoning") + self.print_and_append(reasoning_content) + + def render_content(self, content: str): + self.enter_block("content") + self.print_and_append(content) + + def render_tool_call(self, tool_calls: list[ChatCompletionDeltaToolCall | Any]): + self.enter_block("tool_call") + for tool_call in tool_calls: + # first it says the tool name, then the arguments + if tool_call.function.name is not None: + self.print_and_append(f'Calling tool: "{tool_call.function.name}" ') + self.print_and_append("with args: ") + elif tool_call.function.arguments is not None: + args = tool_call.function.arguments + self.print_and_append(args) + + def render_stop(self, stop_reason: str): + self.print_and_append("\n") + + def render_unknown(self, chunk: litellm.ModelResponseStream): + self.enter_block("unknown") + self.print_and_append(f"Unknown chunk: {chunk}") + + async def render_chunk(self, chunk: litellm.ModelResponseStream): + if chunk.choices[0].finish_reason is not None: + self.render_stop(chunk.choices[0].finish_reason) + return + elif chunk.choices[0].delta is not None: + # inconsistent behavior between providers, some have multiple fields at once, some don't + if chunk.choices[0].delta.tool_calls is not None: + self.render_tool_call(chunk.choices[0].delta.tool_calls) + elif getattr(chunk.choices[0].delta, "reasoning_content", None) is not None: + text = getattr(chunk.choices[0].delta, "reasoning_content", None) + if text is not None: + self.render_reasoning(text) + elif chunk.choices[0].delta.content is not None: + self.render_content(chunk.choices[0].delta.content) + else: + self.render_unknown(chunk) + + def get_stream_text(self) -> str: + return "".join(self.chunk_texts) + + +class ChunkRawRenderer(ChunkRendererAbstract): + def __init__(self): + self.chunks: list[litellm.ModelResponseStream] = [] + self.current_block_type: str | None = None + + async def render_chunk(self, chunk: litellm.ModelResponseStream): + logger.info(str(chunk)) + self.chunks.append(chunk) + + def get_stream_text(self) -> str: + return "\n".join([str(chunk) for chunk in self.chunks]) + + +@pytest.fixture +def task(tmp_path): + project_path: Path = tmp_path / "test_project" / "project.kiln" + project_path.parent.mkdir() + + project = Project(name="Test Project", path=project_path) + project.save_to_file() + + task = Task( + name="Streaming Test Task", + instruction="Think about it hard! Solve the math problem provided by the user, in a step by step manner. Use the tools provided to solve the math problem. Then use the result in a short sentence about a cat going to the mall. Remember to use the tools for math even if the operation looks easy.", + parent=project, + ) + task.save_to_file() + return task + + +@pytest.fixture +def adapter_factory(task: Task) -> Callable[[str, ModelProviderName], LiteLlmAdapter]: + def create_adapter( + model_id: str, provider_name: ModelProviderName + ) -> LiteLlmAdapter: + adapter = LiteLlmAdapter( + kiln_task=task, + config=LiteLlmConfig( + run_config_properties=KilnAgentRunConfigProperties( + model_name=model_id, + model_provider_name=provider_name, + prompt_id=PromptGenerators.SIMPLE, + structured_output_mode=StructuredOutputMode.unknown, + tools_config=ToolsRunConfig( + tools=[ + KilnBuiltInToolId.ADD_NUMBERS, + KilnBuiltInToolId.SUBTRACT_NUMBERS, + KilnBuiltInToolId.MULTIPLY_NUMBERS, + KilnBuiltInToolId.DIVIDE_NUMBERS, + ], + ), + ) + ), + ) + return adapter + + return create_adapter + + +@pytest.mark.paid +@pytest.mark.parametrize( + "model_id,provider_name", + [ + ("claude_sonnet_4_5_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_5_thinking", ModelProviderName.anthropic), + ("claude_sonnet_4_6_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_6_thinking", ModelProviderName.anthropic), + ("claude_opus_4_5_thinking", ModelProviderName.openrouter), + ("claude_opus_4_5_thinking", ModelProviderName.anthropic), + ("claude_opus_4_6_thinking", ModelProviderName.openrouter), + ("claude_opus_4_6_thinking", ModelProviderName.anthropic), + ("claude_4_5_haiku_thinking", ModelProviderName.openrouter), + ("claude_4_5_haiku_thinking", ModelProviderName.anthropic), + ("minimax_m2_5", ModelProviderName.openrouter), + ], +) +async def test_acompletion_streaming_response( + model_id: str, + provider_name: ModelProviderName, + adapter_factory: Callable[[str, ModelProviderName], LiteLlmAdapter], +): + """Check the accumulated response has all the expected parts""" + adapter = adapter_factory(model_id, provider_name) + + renderer = ChunkRenderer() + + # we proxy all the calls to the original function so we can spy on the return values + captured_responses: list[Tuple[litellm.ModelResponse, litellm.Choices]] = [] + origin_func = adapter.acompletion_checking_response + + async def spy( + *args: Any, **kwargs: Any + ) -> Tuple[litellm.ModelResponse, litellm.Choices]: + nonlocal captured_responses + + result = await origin_func(*args, **kwargs) + captured_responses.append(result) + return result + + with patch.object(adapter, "acompletion_checking_response", side_effect=spy): + task_run = await adapter.invoke( + input="123 + 321 = ?", + on_chunk=renderer.render_chunk, + ) + + # there is one call per thing going on (tool call, content, etc.) + # with our toy task, we expect ~2 or 3 calls (reasoning + tool call -> content) + if len(captured_responses) == 0: + raise RuntimeError( + "captured_responses is empty after invocation - test probably broken due to wrong spy" + ) + + # check we are getting the trace successfully + assert task_run.trace is not None, "Task run trace is None" + assert len(task_run.trace) > 0, "Task run trace is empty" + + assistant_messages: list[litellm.Message] = [] + for model_response, _ in captured_responses: + for choice in model_response.choices: + if isinstance(choice, litellm.Choices): + assistant_messages.append(choice.message) + assert len(assistant_messages) > 0, "No assistant messages found in the trace" + + # we do not know which message the reasoning / content / tool call is in, but we know each one + # should appear in at least one message so we accumulate them here + reasoning_contents: list[str] = [] + contents: list[str] = [] + tool_calls: list[ChatCompletionDeltaToolCall | Any] = [] + for assistant_message in assistant_messages: + reasoning_content = getattr(assistant_message, "reasoning_content", None) + if reasoning_content: + reasoning_contents.append(reasoning_content) + + content = getattr(assistant_message, "content", None) + if content: + contents.append(str(content)) + + _tool_calls = getattr(assistant_message, "tool_calls", None) + if _tool_calls: + tool_calls.extend(_tool_calls) + + # check we got all the expected parts somewhere + assert len(reasoning_contents) > 0, "No reasoning contents found in the trace" + assert len(contents) > 0, "No contents found in the trace" + assert len(tool_calls) > 0, "No tool calls found in the trace" + assert len(tool_calls) == 1, "Expected exactly one tool call (to do the math)" + + # check we got some non-empty reasoning - we should have gotten some reasoning at least somewhere + # usually the toolcall + assert not all( + reasoning_content.strip() == "" for reasoning_content in reasoning_contents + ), "All reasoning contents are empty" + + # check we got some non-empty content (we get empty strings when there is no content) + assert not all(content.strip() == "" for content in contents), ( + "All contents are empty" + ) + + for tool_call in tool_calls: + assert tool_call.function.name is not None, "Tool call name is None" + assert tool_call.function.arguments is not None, "Tool call arguments are None" + assert json.loads(tool_call.function.arguments) is not None, ( + "Tool call arguments are not JSON" + ) + tool_call_args = json.loads(tool_call.function.arguments) + assert tool_call_args == { + "a": 123, + "b": 321, + } or tool_call_args == { + "a": 321, + "b": 123, + }, f"Tool call arguments are not the expected values: {tool_call_args}" + + +@pytest.mark.paid +@pytest.mark.parametrize( + "model_id,provider_name", + [ + ("claude_sonnet_4_5_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_5_thinking", ModelProviderName.anthropic), + ("claude_sonnet_4_6_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_6_thinking", ModelProviderName.anthropic), + ("claude_opus_4_5_thinking", ModelProviderName.openrouter), + ("claude_opus_4_5_thinking", ModelProviderName.anthropic), + ("claude_opus_4_6_thinking", ModelProviderName.openrouter), + ("claude_opus_4_6_thinking", ModelProviderName.anthropic), + ("claude_4_5_haiku_thinking", ModelProviderName.openrouter), + ("claude_4_5_haiku_thinking", ModelProviderName.anthropic), + ("minimax_m2_5", ModelProviderName.openrouter), + ], +) +async def test_acompletion_streaming_chunks( + model_id: str, + provider_name: ModelProviderName, + adapter_factory: Callable[[str, ModelProviderName], LiteLlmAdapter], +): + """Collect all chunks from all completion calls, then one pass to check we got reasoning, content, and tool calls.""" + + adapter = adapter_factory(model_id, provider_name) + + chunks: list[litellm.ModelResponseStream] = [] + + renderer = ChunkRenderer() + + async def collect_chunks(chunk: litellm.ModelResponseStream) -> None: + chunks.append(chunk) + await renderer.render_chunk(chunk) + + await adapter.invoke(input="123 + 321 = ?", on_chunk=collect_chunks) + + assert len(chunks) > 0, "No chunks collected" + reasoning_contents: list[str] = [] + contents: list[str] = [] + tool_calls: list[ChatCompletionDeltaToolCall | Any] = [] + + for chunk in chunks: + if chunk.choices[0].finish_reason is not None: + continue + delta = chunk.choices[0].delta + if delta is None: + continue + if delta.tool_calls is not None: + tool_calls.extend(delta.tool_calls) + elif getattr(delta, "reasoning_content", None) is not None: + text = getattr(delta, "reasoning_content", None) + if text is not None: + reasoning_contents.append(text) + elif delta.content is not None: + contents.append(delta.content) + + assert len(reasoning_contents) > 0, "No reasoning content in chunks" + assert len(contents) > 0, "No content in chunks" + assert len(tool_calls) > 0, "No tool calls in chunks" + assert not all(r.strip() == "" for r in reasoning_contents), ( + "All reasoning content in chunks is empty" + ) + assert not all(c.strip() == "" for c in contents), "All content in chunks is empty" + + tool_call_function_names = [ + tool_call.function.name + for tool_call in tool_calls + if tool_call.function.name is not None + ] + assert len(tool_call_function_names) == 1, ( + "Expected exactly one tool call function name" + ) + assert tool_call_function_names[0] == "add", "Tool call function name is not 'add'" + + tool_call_args_chunks = "".join( + [ + tool_call.function.arguments + for tool_call in tool_calls + if tool_call.function.arguments is not None + ] + ) + + tool_call_args = json.loads(tool_call_args_chunks) + assert tool_call_args == {"a": 123, "b": 321} or tool_call_args == { + "a": 321, + "b": 123, + }, f"Tool call arguments not as expected: {tool_call_args}" + + +@pytest.mark.paid +@pytest.mark.parametrize( + "model_id,provider_name", + [ + ("claude_sonnet_4_5_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_5_thinking", ModelProviderName.anthropic), + ("claude_sonnet_4_6_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_6_thinking", ModelProviderName.anthropic), + ("claude_opus_4_5_thinking", ModelProviderName.openrouter), + ("claude_opus_4_5_thinking", ModelProviderName.anthropic), + ("claude_opus_4_6_thinking", ModelProviderName.openrouter), + ("claude_opus_4_6_thinking", ModelProviderName.anthropic), + ("claude_4_5_haiku_thinking", ModelProviderName.openrouter), + ("claude_4_5_haiku_thinking", ModelProviderName.anthropic), + ("minimax_m2_5", ModelProviderName.openrouter), + ], +) +async def test_acompletion_streaming_rendering( + model_id: str, + provider_name: ModelProviderName, + adapter_factory: Callable[[str, ModelProviderName], LiteLlmAdapter], +): + """Test that the streaming response with a renderer to see how it looks""" + adapter = adapter_factory(model_id, provider_name) + renderer = ChunkRenderer() + await adapter.invoke(input="123 + 321 = ?", on_chunk=renderer.render_chunk) + assert renderer.get_stream_text() is not None + + +@pytest.mark.paid +@pytest.mark.parametrize( + "model_id,provider_name", + [ + ("claude_sonnet_4_5_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_5_thinking", ModelProviderName.anthropic), + ("claude_sonnet_4_6_thinking", ModelProviderName.openrouter), + ("claude_sonnet_4_6_thinking", ModelProviderName.anthropic), + ("claude_opus_4_5_thinking", ModelProviderName.openrouter), + ("claude_opus_4_5_thinking", ModelProviderName.anthropic), + ("claude_opus_4_6_thinking", ModelProviderName.openrouter), + ("claude_opus_4_6_thinking", ModelProviderName.anthropic), + ("claude_4_5_haiku_thinking", ModelProviderName.openrouter), + ("claude_4_5_haiku_thinking", ModelProviderName.anthropic), + ("minimax_m2_5", ModelProviderName.openrouter), + ], +) +async def test_acompletion_streaming_rendering_raw_chunks( + model_id: str, + provider_name: ModelProviderName, + adapter_factory: Callable[[str, ModelProviderName], LiteLlmAdapter], +): + """Test that the streaming response with a renderer to see how it looks, but with raw chunks""" + adapter = adapter_factory(model_id, provider_name) + renderer = ChunkRawRenderer() + await adapter.invoke(input="123 + 321 = ?", on_chunk=renderer.render_chunk) + assert renderer.get_stream_text() is not None diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py index fb7bc4c21..bc15622ed 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py +++ b/libs/core/kiln_ai/adapters/model_adapters/test_litellm_adapter_tools.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from litellm.types.utils import ModelResponse @@ -89,7 +89,15 @@ async def run_simple_task_with_tools( with patch.object(adapter, "available_tools", return_value=mock_math_tools): if simplified: - run = await adapter.invoke("what is 2+2") + # test our chunking handler also works e2e on real models + received_chunks = [] + + async def on_chunk_handler(chunk): + received_chunks.append(chunk) + + run = await adapter.invoke("what is 2+2", on_chunk=on_chunk_handler) + + assert len(received_chunks) > 0 # Verify that AddTool.run was called with correct parameters add_spy.run.assert_called() @@ -287,10 +295,19 @@ async def test_tools_simplied_mocked(tmp_path): mock_config.open_ai_api_key = "mock_api_key" mock_config.user_id = "test_user" + responses = [mock_response_1, mock_response_2] + + async def mock_acompletion_checking_response(self, on_chunk=None, **kwargs): + if on_chunk is not None: + await on_chunk(Mock()) + response = responses.pop(0) + return response, response.choices[0] + with ( - patch( - "litellm.acompletion", - side_effect=[mock_response_1, mock_response_2], + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=mock_acompletion_checking_response, ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config), ): @@ -386,9 +403,16 @@ async def test_tools_mocked(tmp_path): mock_config.user_id = "test_user" with ( - patch( - "litellm.acompletion", - side_effect=[mock_response_1, mock_response_2, mock_response_3], + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock( + side_effect=[ + (mock_response_1, mock_response_1.choices[0]), + (mock_response_2, mock_response_2.choices[0]), + (mock_response_3, mock_response_3.choices[0]), + ] + ), ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config), ): diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_saving_adapter_results.py b/libs/core/kiln_ai/adapters/model_adapters/test_saving_adapter_results.py index 9cc5fa5d9..85941e0ec 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/test_saving_adapter_results.py +++ b/libs/core/kiln_ai/adapters/model_adapters/test_saving_adapter_results.py @@ -10,7 +10,7 @@ class MockAdapter(BaseAdapter): - async def _run(self, input: InputType) -> tuple[RunOutput, Usage | None]: + async def _run(self, input: InputType, **kwargs) -> tuple[RunOutput, Usage | None]: return RunOutput(output="Test output", intermediate_outputs=None), None def adapter_name(self) -> str: diff --git a/libs/core/kiln_ai/adapters/model_adapters/test_structured_output.py b/libs/core/kiln_ai/adapters/model_adapters/test_structured_output.py index fd5451705..46fa88aa7 100644 --- a/libs/core/kiln_ai/adapters/model_adapters/test_structured_output.py +++ b/libs/core/kiln_ai/adapters/model_adapters/test_structured_output.py @@ -1,7 +1,7 @@ import json from pathlib import Path from typing import Dict -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch import pytest from litellm.types.utils import ModelResponse @@ -10,6 +10,7 @@ from kiln_ai.adapters.adapter_registry import adapter_for_task from kiln_ai.adapters.ml_model_list import built_in_models from kiln_ai.adapters.model_adapters.base_adapter import BaseAdapter, RunOutput, Usage +from kiln_ai.adapters.model_adapters.litellm_adapter import LiteLlmAdapter from kiln_ai.adapters.ollama_tools import ollama_online from kiln_ai.adapters.test_prompt_adaptors import get_all_models_and_providers from kiln_ai.datamodel import PromptId @@ -53,7 +54,7 @@ def __init__(self, kiln_task: datamodel.Task, response: InputType | None): ) self.response = response - async def _run(self, input: str) -> tuple[RunOutput, Usage | None]: + async def _run(self, input: str, **kwargs) -> tuple[RunOutput, Usage | None]: return RunOutput(output=self.response, intermediate_outputs=None), None def adapter_name(self) -> str: @@ -347,9 +348,10 @@ async def test_all_built_in_models_structured_input_mocked(tmp_path): mock_config.groq_api_key = "mock_api_key" with ( - patch( - "litellm.acompletion", - side_effect=[mock_response], + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock(return_value=(mock_response, mock_response.choices[0])), ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config), ): @@ -402,9 +404,15 @@ async def test_structured_input_cot_prompt_builder_mocked(tmp_path): mock_config.groq_api_key = "mock_api_key" with ( - patch( - "litellm.acompletion", - side_effect=[mock_response_1, mock_response_2], + patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock( + side_effect=[ + (mock_response_1, mock_response_1.choices[0]), + (mock_response_2, mock_response_2.choices[0]), + ] + ), ), patch("kiln_ai.utils.config.Config.shared", return_value=mock_config), ): diff --git a/libs/core/kiln_ai/adapters/test_prompt_adaptors.py b/libs/core/kiln_ai/adapters/test_prompt_adaptors.py index 23dd15b0d..187de1ff2 100644 --- a/libs/core/kiln_ai/adapters/test_prompt_adaptors.py +++ b/libs/core/kiln_ai/adapters/test_prompt_adaptors.py @@ -1,9 +1,9 @@ import os from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -from litellm.utils import ModelResponse +from litellm.types.utils import ModelResponse import kiln_ai.datamodel as datamodel from kiln_ai.adapters.adapter_registry import adapter_for_task @@ -113,13 +113,15 @@ async def test_amazon_bedrock(tmp_path): async def test_mock_returning_run(tmp_path): task = build_test_task(tmp_path) - with patch("litellm.acompletion") as mock_acompletion: - # Configure the mock to return a properly structured response - mock_acompletion.return_value = ModelResponse( - model="custom_model", - choices=[{"message": {"content": "mock response"}}], - ) - + mock_response = ModelResponse( + model="custom_model", + choices=[{"message": {"content": "mock response"}}], + ) + with patch.object( + LiteLlmAdapter, + "acompletion_checking_response", + new=AsyncMock(return_value=(mock_response, mock_response.choices[0])), + ): run_config = KilnAgentRunConfigProperties( model_name="custom_model", model_provider_name=ModelProviderName.ollama, diff --git a/libs/core/kiln_ai/adapters/test_prompt_builders.py b/libs/core/kiln_ai/adapters/test_prompt_builders.py index 0f6485e73..7a67ff5c9 100644 --- a/libs/core/kiln_ai/adapters/test_prompt_builders.py +++ b/libs/core/kiln_ai/adapters/test_prompt_builders.py @@ -58,7 +58,7 @@ def test_simple_prompt_builder(tmp_path): class MockAdapter(BaseAdapter): - async def _run(self, input: InputType) -> tuple[RunOutput, Usage | None]: + async def _run(self, input: InputType, **kwargs) -> tuple[RunOutput, Usage | None]: return RunOutput(output="mock response", intermediate_outputs=None), None def adapter_name(self) -> str: diff --git a/libs/core/kiln_ai/datamodel/test_basemodel.py b/libs/core/kiln_ai/datamodel/test_basemodel.py index 92fadb7fc..897a3749f 100644 --- a/libs/core/kiln_ai/datamodel/test_basemodel.py +++ b/libs/core/kiln_ai/datamodel/test_basemodel.py @@ -862,7 +862,7 @@ def individual_lookups(): class MockAdapter(BaseAdapter): """Implementation of BaseAdapter for testing""" - async def _run(self, input): + async def _run(self, input, **kwargs): return RunOutput(output="test output", intermediate_outputs=None), None def adapter_name(self) -> str: diff --git a/libs/core/pyproject.toml b/libs/core/pyproject.toml index d96bd594a..315e77abc 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -74,4 +74,3 @@ Homepage = "https://kiln.tech" Repository = "https://github.com/Kiln-AI/kiln" Documentation = "https://kiln-ai.github.io/Kiln/kiln_core_docs/kiln_ai.html" Issues = "https://github.com/Kiln-AI/kiln/issues" - diff --git a/pyproject.toml b/pyproject.toml index 652c995d2..2c9a1b632 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dev = [ "ruff>=0.15.0", "watchfiles>=1.1.0", "scalar-fastapi>=1.4.3", - "ty>=0.0.2", + "ty==0.0.8", ] [tool.uv] diff --git a/uv.lock b/uv.lock index 2f21e548c..4dd1f62fc 100644 --- a/uv.lock +++ b/uv.lock @@ -1699,7 +1699,7 @@ dev = [ { name = "python-dotenv", specifier = ">=1.0.1" }, { name = "ruff", specifier = ">=0.15.0" }, { name = "scalar-fastapi", specifier = ">=1.4.3" }, - { name = "ty", specifier = ">=0.0.2" }, + { name = "ty", specifier = "==0.0.8" }, { name = "watchfiles", specifier = ">=1.1.0" }, ] @@ -3832,27 +3832,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/e5/15b6aceefcd64b53997fe2002b6fa055f0b1afd23ff6fc3f55f3da944530/ty-0.0.2.tar.gz", hash = "sha256:e02dc50b65dc58d6cb8e8b0d563833f81bf03ed8a7d0b15c6396d486489a7e1d", size = 4762024, upload-time = "2025-12-16T20:13:41.07Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/86/65d4826677d966cf226662767a4a597ebb4b02c432f413673c8d5d3d1ce8/ty-0.0.2-py3-none-linux_armv6l.whl", hash = "sha256:0954a0e0b6f7e06229dd1da3a9989ee9b881a26047139a88eb7c134c585ad22e", size = 9771409, upload-time = "2025-12-16T20:13:28.964Z" }, - { url = "https://files.pythonhosted.org/packages/d4/bc/6ab06b7c109cec608c24ea182cc8b4714e746a132f70149b759817092665/ty-0.0.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d6044b491d66933547033cecc87cb7eb599ba026a3ef347285add6b21107a648", size = 9580025, upload-time = "2025-12-16T20:13:34.507Z" }, - { url = "https://files.pythonhosted.org/packages/54/de/d826804e304b2430f17bb27ae15bcf02380e7f67f38b5033047e3d2523e6/ty-0.0.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fbca7f08e671a35229f6f400d73da92e2dc0a440fba53a74fe8233079a504358", size = 9098660, upload-time = "2025-12-16T20:13:01.278Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/5cd87944ceee02bb0826f19ced54e30c6bb971e985a22768f6be6b1a042f/ty-0.0.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3abd61153dac0b93b284d305e6f96085013a25c3a7ab44e988d24f0a5fcce729", size = 9567693, upload-time = "2025-12-16T20:13:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b1/062aab2c62c5ae01c05d27b97ba022d9ff66f14a3cb9030c5ad1dca797ec/ty-0.0.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21a9f28caafb5742e7d594104e2fe2ebd64590da31aed4745ae8bc5be67a7b85", size = 9556471, upload-time = "2025-12-16T20:13:07.771Z" }, - { url = "https://files.pythonhosted.org/packages/0e/07/856f6647a9dd6e36560d182d35d3b5fb21eae98a8bfb516cd879d0e509f3/ty-0.0.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3ec63fd23ab48e0f838fb54a47ec362a972ee80979169a7edfa6f5c5034849d", size = 9971914, upload-time = "2025-12-16T20:13:18.852Z" }, - { url = "https://files.pythonhosted.org/packages/2e/82/c2e3957dbf33a23f793a9239cfd8bd04b6defd999bd0f6e74d6a5afb9f42/ty-0.0.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e5e2e0293a259c9a53f668c9c13153cc2f1403cb0fe2b886ca054be4ac76517c", size = 10840905, upload-time = "2025-12-16T20:13:37.098Z" }, - { url = "https://files.pythonhosted.org/packages/3b/17/49bd74e3d577e6c88b8074581b7382f532a9d40552cc7c48ceaa83f1d950/ty-0.0.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2511ac02a83d0dc45d4570c7e21ec0c919be7a7263bad9914800d0cde47817", size = 10570251, upload-time = "2025-12-16T20:13:10.319Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9b/26741834069722033a1a0963fcbb63ea45925c6697357e64e361753c6166/ty-0.0.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c482bfbfb8ad18b2e62427d02a0c934ac510c414188a3cf00e16b8acc35482f0", size = 10369078, upload-time = "2025-12-16T20:13:20.851Z" }, - { url = "https://files.pythonhosted.org/packages/94/fc/1d34ec891900d9337169ff9f8252fcaa633ae5c4d36b67effd849ed4f9ac/ty-0.0.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb514711eed3f56d7a130d4885f4b5d8e490fdcd2adac098e5cf175573a0dda3", size = 10121064, upload-time = "2025-12-16T20:13:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/e5/02/e640325956172355ef8deb9b08d991f229230bf9d07f1dbda8c6665a3a43/ty-0.0.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2c37fa26c39e9fbed7c73645ba721968ab44f28b2bfe2f79a4e15965a1c426f", size = 9553817, upload-time = "2025-12-16T20:13:27.057Z" }, - { url = "https://files.pythonhosted.org/packages/35/13/c93d579ece84895da9b0aae5d34d84100bbff63ad9f60c906a533a087175/ty-0.0.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:13b264833ac5f3b214693fca38e380e78ee7327e09beaa5ff2e47d75fcab9692", size = 9577512, upload-time = "2025-12-16T20:13:16.956Z" }, - { url = "https://files.pythonhosted.org/packages/85/53/93ab1570adc799cd9120ea187d5b4c00d821e86eca069943b179fe0d3e83/ty-0.0.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:08658d6dbbf8bdef80c0a77eda56a22ab6737002ba129301b7bbd36bcb7acd75", size = 9692726, upload-time = "2025-12-16T20:13:31.169Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/5fff5335858a14196776207d231c32e23e48a5c912a7d52c80e7a3fa6f8f/ty-0.0.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4a21b5b012061cb13d47edfff6be70052694308dba633b4c819b70f840e6c158", size = 10213996, upload-time = "2025-12-16T20:13:14.606Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d3/896b1439ab765c57a8d732f73c105ec41142c417a582600638385c2bee85/ty-0.0.2-py3-none-win32.whl", hash = "sha256:d773fdad5d2b30f26313204e6b191cdd2f41ab440a6c241fdb444f8c6593c288", size = 9204906, upload-time = "2025-12-16T20:13:25.099Z" }, - { url = "https://files.pythonhosted.org/packages/5d/0a/f30981e7d637f78e3d08e77d63b818752d23db1bc4b66f9e82e2cb3d34f8/ty-0.0.2-py3-none-win_amd64.whl", hash = "sha256:d1c9ac78a8aa60d0ce89acdccf56c3cc0fcb2de07f1ecf313754d83518e8e8c5", size = 10066640, upload-time = "2025-12-16T20:13:04.045Z" }, - { url = "https://files.pythonhosted.org/packages/5a/c4/97958503cf62bfb7908d2a77b03b91a20499a7ff405f5a098c4989589f34/ty-0.0.2-py3-none-win_arm64.whl", hash = "sha256:fbdef644ade0cd4420c4ec14b604b7894cefe77bfd8659686ac2f6aba9d1a306", size = 9572022, upload-time = "2025-12-16T20:13:39.189Z" }, +version = "0.0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/9d/59e955cc39206a0d58df5374808785c45ec2a8a2a230eb1638fbb4fe5c5d/ty-0.0.8.tar.gz", hash = "sha256:352ac93d6e0050763be57ad1e02087f454a842887e618ec14ac2103feac48676", size = 4828477, upload-time = "2025-12-29T13:50:07.193Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/2b/dd61f7e50a69c72f72c625d026e9ab64a0db62b2dd32e7426b520e2429c6/ty-0.0.8-py3-none-linux_armv6l.whl", hash = "sha256:a289d033c5576fa3b4a582b37d63395edf971cdbf70d2d2e6b8c95638d1a4fcd", size = 9853417, upload-time = "2025-12-29T13:50:08.979Z" }, + { url = "https://files.pythonhosted.org/packages/90/72/3f1d3c64a049a388e199de4493689a51fc6aa5ff9884c03dea52b4966657/ty-0.0.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:788ea97dc8153a94e476c4d57b2551a9458f79c187c4aba48fcb81f05372924a", size = 9657890, upload-time = "2025-12-29T13:50:27.867Z" }, + { url = "https://files.pythonhosted.org/packages/71/d1/08ac676bd536de3c2baba0deb60e67b3196683a2fabebfd35659d794b5e9/ty-0.0.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b5f1f3d3e230f35a29e520be7c3d90194a5229f755b721e9092879c00842d31", size = 9180129, upload-time = "2025-12-29T13:50:22.842Z" }, + { url = "https://files.pythonhosted.org/packages/af/93/610000e2cfeea1875900f73a375ba917624b0a008d4b8a6c18c894c8dbbc/ty-0.0.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6da9ed377fbbcec0a3b60b2ca5fd30496e15068f47cef2344ba87923e78ba996", size = 9683517, upload-time = "2025-12-29T13:50:18.658Z" }, + { url = "https://files.pythonhosted.org/packages/05/04/bef50ba7d8580b0140be597de5cc0ba9a63abe50d3f65560235f23658762/ty-0.0.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7d0a2bdce5e701d19eb8d46d9da0fe31340f079cecb7c438f5ac6897c73fc5ba", size = 9676279, upload-time = "2025-12-29T13:50:25.207Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b9/2aff1ef1f41b25898bc963173ae67fc8f04ca666ac9439a9c4e78d5cc0ff/ty-0.0.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef9078799d26d3cc65366e02392e2b78f64f72911b599e80a8497d2ec3117ddb", size = 10073015, upload-time = "2025-12-29T13:50:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/df/0e/9feb6794b6ff0a157c3e6a8eb6365cbfa3adb9c0f7976e2abdc48615dd72/ty-0.0.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:54814ac39b4ab67cf111fc0a236818155cf49828976152378347a7678d30ee89", size = 10961649, upload-time = "2025-12-29T13:49:58.717Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3b/faf7328b14f00408f4f65c9d01efe52e11b9bcc4a79e06187b370457b004/ty-0.0.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4baf0a80398e8b6c68fa36ff85045a50ede1906cd4edb41fb4fab46d471f1d4", size = 10676190, upload-time = "2025-12-29T13:50:01.11Z" }, + { url = "https://files.pythonhosted.org/packages/64/a5/cfeca780de7eeab7852c911c06a84615a174d23e9ae08aae42a645771094/ty-0.0.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac8e23c3faefc579686799ef1649af8d158653169ad5c3a7df56b152781eeb67", size = 10438641, upload-time = "2025-12-29T13:50:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8d/8667c7e0ac9f13c461ded487c8d7350f440cd39ba866d0160a8e1b1efd6c/ty-0.0.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b558a647a073d0c25540aaa10f8947de826cb8757d034dd61ecf50ab8dbd77bf", size = 10214082, upload-time = "2025-12-29T13:50:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/f8/11/e563229870e2c1d089e7e715c6c3b7605a34436dddf6f58e9205823020c2/ty-0.0.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8c0104327bf480508bd81f320e22074477df159d9eff85207df39e9c62ad5e96", size = 9664364, upload-time = "2025-12-29T13:50:05.443Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/05b79b778bf5237bcd7ee08763b226130aa8da872cbb151c8cfa2e886203/ty-0.0.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:496f1cb87261dd1a036a5609da80ee13de2e6ee4718a661bfa2afb91352fe528", size = 9679440, upload-time = "2025-12-29T13:50:11.289Z" }, + { url = "https://files.pythonhosted.org/packages/12/b5/23ba887769c4a7b8abfd1b6395947dc3dcc87533fbf86379d3a57f87ae8f/ty-0.0.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2c488031f92a075ae39d13ac6295fdce2141164ec38c5d47aa8dc24ee3afa37e", size = 9808201, upload-time = "2025-12-29T13:50:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/f8/90/5a82ac0a0707db55376922aed80cd5fca6b2e6d6e9bcd8c286e6b43b4084/ty-0.0.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90d6f08c5982fa3e802b8918a32e326153519077b827f91c66eea4913a86756a", size = 10313262, upload-time = "2025-12-29T13:50:03.306Z" }, + { url = "https://files.pythonhosted.org/packages/14/f7/ff97f37f0a75db9495ddbc47738ec4339837867c4bfa145bdcfbd0d1eb2f/ty-0.0.8-py3-none-win32.whl", hash = "sha256:d7f460ad6fc9325e9cc8ea898949bbd88141b4609d1088d7ede02ce2ef06e776", size = 9254675, upload-time = "2025-12-29T13:50:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/af/51/eba5d83015e04630002209e3590c310a0ff1d26e1815af204a322617a42e/ty-0.0.8-py3-none-win_amd64.whl", hash = "sha256:1641fb8dedc3d2da43279d21c3c7c1f80d84eae5c264a1e8daa544458e433c19", size = 10131382, upload-time = "2025-12-29T13:50:13.719Z" }, + { url = "https://files.pythonhosted.org/packages/38/1c/0d8454ff0f0f258737ecfe84f6e508729191d29663b404832f98fa5626b7/ty-0.0.8-py3-none-win_arm64.whl", hash = "sha256:ec74f022f315bede478ecae1277a01ab618e6500c1d68450d7883f5cd6ed554a", size = 9636374, upload-time = "2025-12-29T13:50:16.344Z" }, ] [[package]]