diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fcedf9205..4c35d472d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Unreleased - Logging: Local eval (`.eval`) and JSON (`.json`) log files are now written atomically (temp file + `fsync` + rename), preventing corruption from interrupted writes such as disk-full or process crash. (#2949) +- Model API: `model.generate()` now emits one `ModelEvent` per attempt with per-attempt timing and retry accounting (`call_id`, `attempt`, `call_retries`, `http_retries`). - Checkpointing: In-sandbox restic artifacts moved from the world-listable `/opt/inspect-restic` to root-only `/root/.cache/inspect`, and egress scratch files no longer land in `/tmp`, so agents no longer see evidence of checkpointing. - Eval: `EvalSample` and `EvalSampleSummary` now record `turn_count` and the sample's token limit (`token_limit`, `token_limit_type`, and metered `token_limit_usage`). - Analysis: `samples_df` gains default `turn_count` and `token_limit_usage` columns, and `evals_df` configuration columns gain `token_limit_type`. diff --git a/pyproject.toml b/pyproject.toml index fa8535a254..003e2e34f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,12 @@ norecursedirs = [ ] log_level = "WARNING" +[tool.mutmut] +paths_to_mutate = ["src/inspect_ai/model/_generate_accounting.py"] +tests_dir = ["tests/model/test_generate_accounting.py"] +pytest_add_cli_args = ["-q"] +also_copy = ["src/inspect_ai"] + [tool.mypy] exclude = [ "tests/test_package", diff --git a/requirements-dev.txt b/requirements-dev.txt index ba1cded793..2ccd468afd 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,7 @@ google-genai>=1.69.0 griffe>=2 groq>=0.28.0 huggingface_hub>=1.6.0 +hypothesis inspect-scout>=0.4.37 ipython jsonpath-ng @@ -15,6 +16,7 @@ markdown mcp>=1.10.0 mistralai>=2.4.5, moto[server]>=5.1.5 +mutmut mypy>=1.17.0 nbformat openai>=2.45.0 diff --git a/src/inspect_ai/_util/retry.py b/src/inspect_ai/_util/retry.py index e18bc1a1eb..8aced0b6b7 100644 --- a/src/inspect_ai/_util/retry.py +++ b/src/inspect_ai/_util/retry.py @@ -20,6 +20,7 @@ def report_http_retry( pausing scale-up but not triggering a cut. """ from inspect_ai.log._samples import report_active_sample_retry + from inspect_ai.model._generate_accounting import current_model_generate_accounting from inspect_ai.util._concurrency import _active_controller, _request_had_retry # bump global counter @@ -38,6 +39,11 @@ def report_http_retry( if controller is not None: controller.notify_retry(retry_after=retry_after) + # record on the per-generate accounting (separate from outer scheduled retries) + accounting = current_model_generate_accounting() + if accounting is not None: + accounting.record_http_retry() + def http_retries_count() -> int: return _http_retries_count diff --git a/src/inspect_ai/_view/inspect-openapi.json b/src/inspect_ai/_view/inspect-openapi.json index 75c64eca80..fc3cf1c38a 100644 --- a/src/inspect_ai/_view/inspect-openapi.json +++ b/src/inspect_ai/_view/inspect-openapi.json @@ -7055,6 +7055,17 @@ "ModelEvent": { "description": "Call to a language model.", "properties": { + "attempt": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Attempt" + }, "cache": { "anyOf": [ { @@ -7080,6 +7091,72 @@ } ] }, + "call_completed_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Call Completed At" + }, + "call_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Call Id" + }, + "call_retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Call Retries" + }, + "call_started_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Call Started At" + }, + "call_working_start": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Call Working Start" + }, + "call_working_time": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Call Working Time" + }, "completed": { "anyOf": [ { @@ -7111,6 +7188,17 @@ "title": "Event", "type": "string" }, + "http_retries": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Http Retries" + }, "input": { "items": { "anyOf": [ diff --git a/src/inspect_ai/analysis/_dataframe/events/columns.py b/src/inspect_ai/analysis/_dataframe/events/columns.py index ec49fb3208..655656f850 100644 --- a/src/inspect_ai/analysis/_dataframe/events/columns.py +++ b/src/inspect_ai/analysis/_dataframe/events/columns.py @@ -1,11 +1,13 @@ from datetime import datetime -from typing import Any, Callable, Mapping, Type +from typing import Any, Callable, Mapping, Type, cast from jsonpath_ng import JSONPath # type: ignore from pydantic import JsonValue from typing_extensions import override from inspect_ai.event._event import Event +from inspect_ai.event._model import ModelEvent +from inspect_ai.event._tool import ToolEvent from ..columns import Column, ColumnType from .extract import ( @@ -44,6 +46,22 @@ def path_schema(self) -> Mapping[str, Any] | None: return None +def _model_event_input_as_str(event: Event) -> str: + return model_event_input_as_str(cast(ModelEvent, event)) + + +def _tool_choice_as_str(event: Event) -> str: + return tool_choice_as_str(cast(ModelEvent, event)) + + +def _completion_as_str(event: Event) -> str: + return completion_as_str(cast(ModelEvent, event)) + + +def _tool_view_as_str(event: Event) -> str | None: + return tool_view_as_str(cast(ToolEvent, event)) + + EventInfo: list[Column] = [ EventColumn("event_id", path="uuid"), EventColumn("event", path="event"), @@ -59,30 +77,48 @@ def path_schema(self) -> Mapping[str, Any] | None: ] """Event timing columns.""" -ModelEventColumns: list[Column] = [ - EventColumn("model_event_model", path="model"), - EventColumn("model_event_role", path="role"), - EventColumn("model_event_input", path=model_event_input_as_str), - EventColumn("model_event_tools", path="tools"), - EventColumn("model_event_tool_choice", path=tool_choice_as_str), - EventColumn("model_event_config", path="config"), - EventColumn("model_event_usage", path="output.usage"), - EventColumn("model_event_time", path="output.time"), - EventColumn("model_event_completion", path=completion_as_str), - EventColumn("model_event_retries", path="retries"), - EventColumn("model_event_error", path="error"), - EventColumn("model_event_cache", path="cache"), - EventColumn("model_event_call", path="call"), -] +ModelEventColumns: list[Column] = cast( + list[Column], + [ + EventColumn("model_event_model", path="model"), + EventColumn("model_event_role", path="role"), + EventColumn("model_event_input", path=_model_event_input_as_str), + EventColumn("model_event_tools", path="tools"), + EventColumn("model_event_tool_choice", path=_tool_choice_as_str), + EventColumn("model_event_config", path="config"), + EventColumn("model_event_usage", path="output.usage"), + EventColumn("model_event_time", path="output.time"), + EventColumn("model_event_completion", path=_completion_as_str), + EventColumn("model_event_retries", path="retries"), + EventColumn("model_event_error", path="error"), + EventColumn("model_event_cache", path="cache"), + EventColumn("model_event_call", path="call"), + EventColumn("model_event_call_id", path="call_id"), + EventColumn("model_event_attempt", path="attempt"), + EventColumn( + "model_event_call_started_at", path="call_started_at", type=datetime + ), + EventColumn( + "model_event_call_completed_at", path="call_completed_at", type=datetime + ), + EventColumn("model_event_call_working_start", path="call_working_start"), + EventColumn("model_event_call_working_time", path="call_working_time"), + EventColumn("model_event_call_retries", path="call_retries"), + EventColumn("model_event_http_retries", path="http_retries"), + ], +) """Model event columns.""" -ToolEventColumns: list[Column] = [ - EventColumn("tool_event_function", path="function"), - EventColumn("tool_event_arguments", path="arguments"), - EventColumn("tool_event_view", path=tool_view_as_str), - EventColumn("tool_event_result", path="result"), - EventColumn("tool_event_truncated", path="truncated"), - EventColumn("tool_event_error_type", path="error.type"), - EventColumn("tool_event_error_message", path="error.message"), -] +ToolEventColumns: list[Column] = cast( + list[Column], + [ + EventColumn("tool_event_function", path="function"), + EventColumn("tool_event_arguments", path="arguments"), + EventColumn("tool_event_view", path=_tool_view_as_str), + EventColumn("tool_event_result", path="result"), + EventColumn("tool_event_truncated", path="truncated"), + EventColumn("tool_event_error_type", path="error.type"), + EventColumn("tool_event_error_message", path="error.message"), + ], +) """Tool event columns.""" diff --git a/src/inspect_ai/event/_model.py b/src/inspect_ai/event/_model.py index 37fe1006ea..b835c09937 100644 --- a/src/inspect_ai/event/_model.py +++ b/src/inspect_ai/event/_model.py @@ -82,7 +82,11 @@ class ModelEvent(BaseEvent): """Output from model.""" retries: int | None = Field(default=None) - """Retries for the model API request.""" + """Legacy retry count. + + On terminal post-fix events, mirrors ``http_retries`` if non-zero, else + ``call_retries``. Prefer ``call_retries`` and ``http_retries`` for new code. + """ error: str | None = Field(default=None) """Error which occurred during model call.""" @@ -100,13 +104,49 @@ class ModelEvent(BaseEvent): """Raw call made to model API.""" completed: UtcDatetime | None = Field(default=None) - """Time that model call completed (see `timestamp` for started)""" + """Time this attempt completed.""" working_time: float | None = Field(default=None) - """working time for model call that succeeded (i.e. was not retried).""" + """Working-clock duration of this attempt.""" + + call_id: str | None = Field(default=None) + """Stable id shared by all ModelEvents from one logical generate call.""" + + attempt: int | None = Field(default=None) + """1-based outer-attempt number within ``call_id``. None on legacy logs.""" + + call_started_at: UtcDatetime | None = Field(default=None) + """Wall-clock time the logical generate call began. Terminal event only.""" + + call_completed_at: UtcDatetime | None = Field(default=None) + """Wall-clock time the logical generate call completed. Terminal event only.""" + + call_working_start: float | None = Field(default=None) + """Sample working-clock value when the logical generate call began.""" + + call_working_time: float | None = Field(default=None) + """Logical generate working-clock duration. Terminal event only.""" + + call_retries: int | None = Field(default=None) + """Number of outer Tenacity retries actually scheduled. Terminal event only.""" + + http_retries: int | None = Field(default=None) + """Total retry signals reported via ``report_http_retry``. Terminal event only.""" @field_serializer("completed") def serialize_completed(self, dt: datetime | None) -> str | None: if dt is None: return None return datetime_to_iso_format_safe(dt) + + @field_serializer("call_started_at") + def serialize_call_started_at(self, dt: datetime | None) -> str | None: + if dt is None: + return None + return datetime_to_iso_format_safe(dt) + + @field_serializer("call_completed_at") + def serialize_call_completed_at(self, dt: datetime | None) -> str | None: + if dt is None: + return None + return datetime_to_iso_format_safe(dt) diff --git a/src/inspect_ai/hooks/_hooks.py b/src/inspect_ai/hooks/_hooks.py index 00d8638652..1628b3bff7 100644 --- a/src/inspect_ai/hooks/_hooks.py +++ b/src/inspect_ai/hooks/_hooks.py @@ -16,10 +16,9 @@ registry_find, registry_name, ) -from inspect_ai.event import Event +from inspect_ai.event._event import Event from inspect_ai.hooks._legacy import override_api_key_legacy from inspect_ai.log._log import EvalLog, EvalSample, EvalSampleSummary, EvalSpec -from inspect_ai.log._samples import sample_active from inspect_ai.model._chat_message import ChatMessage from inspect_ai.model._generate_config import GenerateConfig from inspect_ai.model._model_output import ModelUsage @@ -245,6 +244,12 @@ class ModelUsageData: """The name of the task that generated this usage (if any).""" retries: int = 0 """The number of HTTP retries made before the successful call.""" + call_retries: int | None = None + """The number of outer model-generate retries scheduled before success.""" + http_retries: int | None = None + """The number of HTTP/provider retry signals before success.""" + call_working_time: float | None = None + """Logical model-generate working time, excluding retry sleeps.""" @dataclass(frozen=True) @@ -590,13 +595,12 @@ def hooks(name: str, description: str) -> Callable[..., Type[T]]: def wrapper(hook_type: Type[T] | Callable[..., Type[T]]) -> Type[T]: # Resolve the hook type if it's a function. - if not isinstance(hook_type, type): - hook_type = hook_type() - if not issubclass(hook_type, Hooks): - raise TypeError(f"Hook must be a subclass of Hooks, got {hook_type}") + hook_class = hook_type if isinstance(hook_type, type) else hook_type() + if not issubclass(hook_class, Hooks): + raise TypeError(f"Hook must be a subclass of Hooks, got {hook_class}") # Instantiate an instance of the Hooks class. - hook_instance = hook_type() + hook_instance = hook_class() hook_name = registry_name(hook_instance, name) registry_add( hook_instance, @@ -604,7 +608,7 @@ def wrapper(hook_type: Type[T] | Callable[..., Type[T]]) -> Type[T]: type="hooks", name=hook_name, metadata={"description": description} ), ) - return hook_type + return cast(Type[T], hook_class) return wrapper @@ -703,6 +707,8 @@ def emit_sample_event( sample_id: str, event: Event, ) -> None: + from inspect_ai.log._samples import sample_active + active = sample_active() if active is None or active.event_send is None: return @@ -726,6 +732,8 @@ def start_sample_event_emitter() -> None: Must be called after active.start(tg) so that the task group is available. """ + from inspect_ai.log._samples import sample_active + active = sample_active() if active is None or active.tg is None: return @@ -763,6 +771,8 @@ async def drain_sample_events() -> None: Must be called before emit_sample_end() to ensure all queued events are delivered before the sample end hook fires. """ + from inspect_ai.log._samples import sample_active + active = sample_active() if active is None: return @@ -889,7 +899,12 @@ async def emit_sample_attempt_end( async def emit_model_usage( - model_name: str, usage: ModelUsage, call_duration: float + model_name: str, + usage: ModelUsage, + call_duration: float, + call_retries: int | None = None, + http_retries: int | None = None, + call_working_time: float | None = None, ) -> None: from inspect_ai.log._samples import sample_active @@ -922,6 +937,9 @@ async def emit_model_usage( eval_id=eval_id, task_name=task_name, retries=retries, + call_retries=call_retries, + http_retries=http_retries, + call_working_time=call_working_time, ) await _emit_to_all(lambda hook: hook.on_model_usage(data)) @@ -932,6 +950,8 @@ async def emit_model_cache_usage(model_name: str, usage: ModelUsage) -> None: async def emit_model_retry(model_name: str, attempt: int, wait_time: float) -> None: + from inspect_ai.log._samples import sample_active + # Read eval context from the active sample contextvar (if available). active = sample_active() eval_set_id: str | None = None diff --git a/src/inspect_ai/model/_generate_accounting.py b/src/inspect_ai/model/_generate_accounting.py new file mode 100644 index 0000000000..026c72e887 --- /dev/null +++ b/src/inspect_ai/model/_generate_accounting.py @@ -0,0 +1,93 @@ +"""Per-call accounting for Model.generate() retry semantics.""" + +from __future__ import annotations + +import contextlib +from collections.abc import AsyncIterator +from contextvars import ContextVar +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING + +from shortuuid import uuid + +if TYPE_CHECKING: + from inspect_ai.event._model import ModelEvent + + +@dataclass +class ModelGenerateAccounting: + """Accumulates per-call counters and timing for one generate invocation.""" + + call_id: str + started_at: datetime + working_start: float + attempt_count: int = 0 + call_retry_count: int = 0 + http_retry_count: int = 0 + last_event: ModelEvent | None = None + _terminal_finalized: bool = field(default=False, repr=False) + + @classmethod + def new( + cls, *, started_at: datetime, working_start: float + ) -> ModelGenerateAccounting: + """Create a new accounting context with a stable opaque call id.""" + return cls(call_id=uuid(), started_at=started_at, working_start=working_start) + + def register_event(self, event: ModelEvent) -> None: + """Stamp call id and attempt on ``event`` and remember it.""" + self.attempt_count += 1 + event.call_id = self.call_id + event.attempt = self.attempt_count + self.last_event = event + + def record_call_retry(self) -> None: + """Record one outer Tenacity retry that will actually be scheduled.""" + self.call_retry_count += 1 + + def record_http_retry(self) -> None: + """Record one provider/HTTP retry signal.""" + self.http_retry_count += 1 + + def finalize_terminal_event( + self, *, event: ModelEvent, completed_at: datetime, working_now: float + ) -> None: + """Set call-level fields on the terminal event. Idempotent.""" + if self._terminal_finalized: + return + self._terminal_finalized = True + + event.call_started_at = self.started_at + event.call_completed_at = completed_at + event.call_working_start = self.working_start + event.call_working_time = max(0.0, working_now - self.working_start) + event.call_retries = self.call_retry_count + event.http_retries = self.http_retry_count + event.retries = ( + self.http_retry_count + if self.http_retry_count > 0 + else self.call_retry_count + ) + + +_accounting: ContextVar[ModelGenerateAccounting | None] = ContextVar( + "_model_generate_accounting", default=None +) + + +def current_model_generate_accounting() -> ModelGenerateAccounting | None: + """Return the current generate accounting context, if any.""" + return _accounting.get() + + +@contextlib.asynccontextmanager +async def model_generate_accounting( + accounting: ModelGenerateAccounting, +) -> AsyncIterator[None]: + """Set accounting for the current task and restore the previous value.""" + token = _accounting.set(accounting) + try: + yield + finally: + _accounting.reset(token) diff --git a/src/inspect_ai/model/_model.py b/src/inspect_ai/model/_model.py index 62d43523f6..1a0cc71309 100644 --- a/src/inspect_ai/model/_model.py +++ b/src/inspect_ai/model/_model.py @@ -69,6 +69,11 @@ from inspect_ai._util.rich import format_traceback from inspect_ai._util.trace import trace_action from inspect_ai._util.working import report_sample_waiting_time, sample_working_time +from inspect_ai.model._generate_accounting import ( + ModelGenerateAccounting, + current_model_generate_accounting, + model_generate_accounting, +) from inspect_ai.model._generate_overrides import generate_config_override from inspect_ai.model._retry import model_retry_config from inspect_ai.tool import Tool, ToolChoice, ToolFunction, ToolInfo @@ -827,38 +832,63 @@ async def generate( # enforce concurrency limits start_time = datetime.now(timezone.utc) working_start = sample_working_time() + accounting = ModelGenerateAccounting.new( + started_at=start_time, working_start=working_start + ) async with self._connection_concurrency(config): - # generate - output, event = await self._generate( - input=input, - tools=tools, - tool_choice=tool_choice, - config=config, - cache=cache, - ) + async with model_generate_accounting(accounting): + try: + output, event = await self._generate( + input=input, + tools=tools, + tool_choice=tool_choice, + config=config, + cache=cache, + ) + except BaseException as ex: + from inspect_ai.log._transcript import transcript + + if accounting.last_event is not None: + terminal_event = accounting.last_event + if terminal_event.pending: + terminal_event.error = ( + exception_message(ex) + if isinstance(ex, Exception) + else type(ex).__name__ + ) + if isinstance(ex, Exception): + traceback_text, traceback_ansi = format_traceback( + type(ex), ex, ex.__traceback__ + ) + terminal_event.traceback = traceback_text + terminal_event.traceback_ansi = traceback_ansi + terminal_event.completed = datetime.now(timezone.utc) + terminal_event.working_time = max( + 0.0, + sample_working_time() - terminal_event.working_start, + ) + terminal_event.pending = None + accounting.finalize_terminal_event( + event=terminal_event, + completed_at=datetime.now(timezone.utc), + working_now=sample_working_time(), + ) + transcript()._event_updated(terminal_event) # pyright: ignore[reportPrivateUsage] + raise - # update the most recent ModelEvent with the actual start/completed - # times as well as a computation of working time (events are - # created _after_ the call to _generate, potentially in response - # to retries, so they need their timestamp updated so it accurately - # reflects the full start/end time which we know here) - from inspect_ai.event._model import ModelEvent - - assert isinstance(event, ModelEvent) - event.timestamp = start_time - event.working_start = working_start - completed = datetime.now(timezone.utc) - event.completed = completed - event.working_time = ( - output.time - if output.time is not None - else (completed - start_time).total_seconds() - ) + from inspect_ai.event._model import ModelEvent + from inspect_ai.log._transcript import transcript - _stamp_redacted_reasoning_tokens(output) + _stamp_redacted_reasoning_tokens(output) - # return output - return output + assert isinstance(event, ModelEvent) + accounting.finalize_terminal_event( + event=event, + completed_at=datetime.now(timezone.utc), + working_now=sample_working_time(), + ) + transcript()._event_updated(event) # pyright: ignore[reportPrivateUsage] + return output async def generate_loop( self, @@ -932,8 +962,8 @@ async def count_tokens( @retry( **model_retry_config( self.api.model_name, - self.config.max_retries, - self.config.timeout, + config.max_retries, + config.timeout, self.should_retry, self.before_retry, log_model_retry, @@ -1012,8 +1042,8 @@ async def compact( @retry( **model_retry_config( self.api.model_name, - self.config.max_retries, - self.config.timeout, + config.max_retries, + config.timeout, self.should_retry, self.before_retry, log_model_retry, @@ -1134,6 +1164,7 @@ def report_waiting_time(waiting_time: float) -> None: log_model_retry, report_waiting_time, self.api.retry_wait(), + on_retry_scheduled=lambda retry_state: _record_call_retry_if_active(), ) ) async def generate() -> tuple[ModelOutput, BaseModel]: @@ -1320,8 +1351,18 @@ async def generate() -> tuple[ModelOutput, BaseModel]: record_and_check_model_usage(self, output.usage, role=self.role) # send telemetry to hooks + acc = current_model_generate_accounting() await emit_model_usage( - model_name=str(self), usage=output.usage, call_duration=output.time + model_name=str(self), + usage=output.usage, + call_duration=output.time, + call_retries=acc.call_retry_count if acc else None, + http_retries=acc.http_retry_count if acc else None, + call_working_time=max( + 0.0, sample_working_time() - acc.working_start + ) + if acc + else None, ) await send_telemetry_legacy( "model_usage", @@ -1387,9 +1428,18 @@ async def generate() -> tuple[ModelOutput, BaseModel]: # any remaining waiting time will have been due to internal retry within # model providers, which we can get from: # total_time - reported_waiting_time - model_call_time - report_sample_waiting_time( - total_time - reported_waiting_time - model_output.time - ) + extra_waiting = total_time - reported_waiting_time - model_output.time + if extra_waiting < 0: + logger.debug( + "clamped negative waiting delta: total=%.4f reported=%.4f " + + "output_time=%.4f delta=%.4f", + total_time, + reported_waiting_time, + model_output.time, + extra_waiting, + ) + extra_waiting = 0.0 + report_sample_waiting_time(extra_waiting) # report refusal if not model_output.empty and model_output.stop_reason == "content_filter": @@ -1589,6 +1639,9 @@ def _record_model_interaction( call=call, pending=output is None, ) + accounting = current_model_generate_accounting() + if accounting is not None: + accounting.register_event(event) sink = _model_event_sink.get() if sink is None: transcript()._event(event) @@ -1635,13 +1688,22 @@ def complete( # We try to set these in the individual providers' error handling, but we make a last # ditch effort here to set them if we don't have a response. event.call.error = True - if hasattr(result, "body"): - event.call.response = as_error_response(result.body) - elif hasattr(result, "response"): - event.call.response = as_error_response(result.response) + body = cast(object | None, getattr(result, "body", None)) + response = cast(object | None, getattr(result, "response", None)) + if body is not None: + event.call.response = as_error_response(body) + elif response is not None: + event.call.response = as_error_response(response) else: event.call.response = as_error_response(str(result)) + if event.completed is None: + event.completed = datetime.now(timezone.utc) + if event.working_time is None: + event.working_time = max( + 0.0, sample_working_time() - event.working_start + ) + event.pending = None if sink is None: transcript()._event_updated(event) @@ -1707,6 +1769,12 @@ def __init__( self.provider_message = provider_message +def _record_call_retry_if_active() -> None: + accounting = current_model_generate_accounting() + if accounting is not None: + accounting.record_call_retry() + + class ModelName: r"""Model name (api and specific model served by the api). diff --git a/src/inspect_ai/model/_retry.py b/src/inspect_ai/model/_retry.py index 93902a9684..27a3850c9f 100644 --- a/src/inspect_ai/model/_retry.py +++ b/src/inspect_ai/model/_retry.py @@ -34,6 +34,8 @@ def model_retry_config( report_waiting_time: Callable[[float], None] | None = None, wait: WaitBaseT | None = None, live_overrides: bool = True, + on_retry_scheduled: Callable[[RetryCallState], (Awaitable[None] | None)] + | None = None, ) -> ModelRetryConfig: # retry for transient http errors: # - use config.max_retries and config.timeout if specified, otherwise retry forever @@ -57,6 +59,11 @@ async def on_before_sleep(rs: RetryCallState) -> None: if report_waiting_time is not None: report_waiting_time(rs.upcoming_sleep) + if on_retry_scheduled is not None: + res = on_retry_scheduled(rs) + if res is not None: + await res + res = log_model_retry(model_name, rs) if res is not None: await res diff --git a/tests/_helpers/__init__.py b/tests/_helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/_helpers/event_assertions.py b/tests/_helpers/event_assertions.py new file mode 100644 index 0000000000..98816d1652 --- /dev/null +++ b/tests/_helpers/event_assertions.py @@ -0,0 +1,107 @@ +"""Event-stream assertion helpers for ModelEvent retry-timing tests.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Literal, Protocol, cast + +from inspect_ai.event._event import Event +from inspect_ai.event._model import ModelEvent +from inspect_ai.log._transcript import Transcript + +TerminalKind = Literal["success", "exhausted", "cache"] + + +class HasEvents(Protocol): + events: Iterable[Event] + + +def model_events(source: object) -> list[ModelEvent]: + """Extract ModelEvents from a Transcript, sample-like object, or list.""" + events: Iterable[Event] + if isinstance(source, Transcript): + events = source.events + elif hasattr(source, "events"): + events = cast(HasEvents, source).events + elif isinstance(source, list): + events = source + else: + raise TypeError(f"Cannot extract events from {type(source).__name__}") + return [event for event in events if isinstance(event, ModelEvent)] + + +def assert_no_legacy_rewrite(events: list[ModelEvent]) -> None: + """Assert timestamps do not invert within a post-fix call group.""" + by_call: dict[str | None, list[ModelEvent]] = {} + for event in events: + by_call.setdefault(event.call_id, []).append(event) + for call_id, group in by_call.items(): + for prev, curr in zip(group, group[1:]): + if curr.timestamp < prev.timestamp: + raise AssertionError( + f"timestamp inversion in call_id={call_id!r}: " + f"attempt={curr.attempt} timestamp={curr.timestamp.isoformat()} " + f"is before previous attempt={prev.attempt} " + f"timestamp={prev.timestamp.isoformat()}" + ) + + +def assert_attempt_group( + events: list[ModelEvent], *, retries: int, terminal_kind: TerminalKind +) -> None: + """Assert events form one valid call_id group.""" + if not events: + raise AssertionError("expected at least one event in attempt group") + call_ids = {event.call_id for event in events} + if len(call_ids) != 1 or None in call_ids: + raise AssertionError(f"events do not share one call_id: {call_ids}") + attempts = [event.attempt for event in events] + expected = list(range(1, retries + 2)) + if attempts != expected: + raise AssertionError( + f"attempts not contiguous 1..{retries + 1}: got {attempts}" + ) + assert_no_legacy_rewrite(events) + assert_call_field_invariants(events[-1], kind=f"terminal-{terminal_kind}") + for event in events[:-1]: + assert_call_field_invariants(event, kind="non-terminal") + + +def assert_call_field_invariants(event: ModelEvent, *, kind: str) -> None: + """Assert a ModelEvent has the expected call-level field shape.""" + call_fields = ( + event.call_started_at, + event.call_completed_at, + event.call_working_start, + event.call_working_time, + event.call_retries, + event.http_retries, + ) + if kind == "terminal-success": + if any(field is None for field in call_fields): + raise AssertionError( + f"terminal-success missing call_* fields: {call_fields}" + ) + if event.error is not None: + raise AssertionError("terminal-success has error set") + if event.cache == "read": + raise AssertionError("terminal-success has cache='read'") + elif kind == "terminal-exhausted": + if any(field is None for field in call_fields): + raise AssertionError( + f"terminal-exhausted missing call_* fields: {call_fields}" + ) + if event.error is None: + raise AssertionError("terminal-exhausted has no error") + elif kind == "terminal-cache": + if any(field is None for field in call_fields): + raise AssertionError(f"terminal-cache missing call_* fields: {call_fields}") + if event.cache != "read": + raise AssertionError(f"terminal-cache has cache={event.cache!r}") + elif kind == "non-terminal": + if any(field is not None for field in call_fields): + raise AssertionError(f"non-terminal has call_* fields set: {call_fields}") + if event.error is None: + raise AssertionError("non-terminal has no error") + else: + raise ValueError(f"unknown kind: {kind!r}") diff --git a/tests/_helpers/fixtures/README.md b/tests/_helpers/fixtures/README.md new file mode 100644 index 0000000000..b4858edf81 --- /dev/null +++ b/tests/_helpers/fixtures/README.md @@ -0,0 +1,12 @@ +# Golden fixtures + +Two committed `.eval` snapshots exercise pre-fix and post-fix `ModelEvent` retry timing behavior. + +## Layout + +- `legacy/retry-2-then-success.eval` was captured before this fix. It lacks `call_id`, `attempt`, and `call_*` fields and preserves the legacy timestamp-rewrite signature. +- `postfix/retry-2-then-success.eval` was captured after this fix. It has a shared `call_id`, contiguous `attempt` values, and non-decreasing per-attempt timestamps. + +## Regeneration + +The fixtures are generated by running a one-sample eval with a fake provider that raises two retryable exceptions and then returns `ok` with `wait_fixed(0)` retry waits. Regenerate `legacy/` only from a pre-fix checkout; regenerate `postfix/` from the current implementation. diff --git a/tests/_helpers/fixtures/legacy/retry-2-then-success.eval b/tests/_helpers/fixtures/legacy/retry-2-then-success.eval new file mode 100644 index 0000000000..c80682d0b5 Binary files /dev/null and b/tests/_helpers/fixtures/legacy/retry-2-then-success.eval differ diff --git a/tests/_helpers/fixtures/postfix/retry-2-then-success.eval b/tests/_helpers/fixtures/postfix/retry-2-then-success.eval new file mode 100644 index 0000000000..201d5ffd1f Binary files /dev/null and b/tests/_helpers/fixtures/postfix/retry-2-then-success.eval differ diff --git a/tests/_helpers/retry_provider.py b/tests/_helpers/retry_provider.py new file mode 100644 index 0000000000..03228a1ad3 --- /dev/null +++ b/tests/_helpers/retry_provider.py @@ -0,0 +1,142 @@ +"""Test helpers for retry-path scenarios.""" + +from __future__ import annotations + +from collections.abc import Callable + +import anyio +from tenacity import wait_fixed +from tenacity.wait import WaitBaseT + +from inspect_ai._util.registry import REGISTRY_INFO, RegistryInfo +from inspect_ai.model import GenerateConfig, Model, get_model +from inspect_ai.model._chat_message import ChatMessage +from inspect_ai.model._model import ModelAPI +from inspect_ai.model._model_call import ModelCall +from inspect_ai.model._model_output import ModelOutput +from inspect_ai.tool import ToolChoice, ToolInfo + + +class RetryableModelError(RuntimeError): + """Test exception classified as retryable by retry-test helpers.""" + + +class RaisingThenSucceedingAPI(ModelAPI): + """Async fake provider that raises ``failures`` times then succeeds.""" + + def __init__( + self, + *, + failures: int, + success_content: str = "ok", + success_output_time: float = 0.001, + model_name: str = "fake", + ) -> None: + super().__init__( + model_name=model_name, + base_url=None, + api_key=None, + api_key_vars=[], + config=GenerateConfig(), + ) + setattr( + self, + REGISTRY_INFO, + RegistryInfo(type="modelapi", name="inspect_ai/mockllm"), + ) + self._remaining = failures + self._success_content = success_content + self._success_output_time = success_output_time + + async def generate( + self, + input: list[ChatMessage], + tools: list[ToolInfo], + tool_choice: ToolChoice, + config: GenerateConfig, + ) -> ModelOutput | tuple[ModelOutput, ModelCall]: + if self._remaining > 0: + self._remaining -= 1 + raise RetryableModelError(f"transient (remaining={self._remaining})") + out = ModelOutput.from_content(self.model_name, self._success_content) + out.time = self._success_output_time + model_call = ModelCall.create( + request={"input": [message.model_dump() for message in input]}, + response={"content": out.completion}, + time=self._success_output_time, + ) + return out, model_call + + def should_retry(self, ex: Exception) -> bool: + return isinstance(ex, RetryableModelError) + + def retry_wait(self) -> WaitBaseT: + return wait_fixed(0) + + +class SlowThenSuccessAPI(ModelAPI): + """First attempt sleeps ``slow_seconds``, then succeeds.""" + + def __init__(self, *, slow_seconds: float, model_name: str = "fake") -> None: + super().__init__( + model_name=model_name, + base_url=None, + api_key=None, + api_key_vars=[], + config=GenerateConfig(), + ) + setattr( + self, + REGISTRY_INFO, + RegistryInfo(type="modelapi", name="inspect_ai/mockllm"), + ) + self._first = True + self._slow_seconds = slow_seconds + + async def generate( + self, + input: list[ChatMessage], + tools: list[ToolInfo], + tool_choice: ToolChoice, + config: GenerateConfig, + ) -> ModelOutput | tuple[ModelOutput, ModelCall]: + if self._first: + self._first = False + await anyio.sleep(self._slow_seconds) + out = ModelOutput.from_content(self.model_name, "ok") + out.time = 0.001 + model_call = ModelCall.create( + request={"input": [message.model_dump() for message in input]}, + response={"content": out.completion}, + time=0.001, + ) + return out, model_call + + def should_retry(self, ex: Exception) -> bool: + return isinstance(ex, RetryableModelError) + + def retry_wait(self) -> WaitBaseT: + return wait_fixed(0) + + +def install_retry_classifier(model: Model) -> None: + """Monkeypatch ``model.api.should_retry`` for retry-test exceptions.""" + original = getattr(model.api, "should_retry", None) + + def classifier(ex: Exception) -> bool: + if isinstance(ex, RetryableModelError): + return True + if original is not None: + return bool(original(ex)) + return False + + model.api.should_retry = classifier # type: ignore[method-assign] + model.api.retry_wait = lambda: wait_fixed(0) # type: ignore[method-assign] + + +def make_mockllm_with_callable( + custom_outputs: Callable[..., ModelOutput], + model_name: str = "mockllm/test", +) -> Model: + """Construct a non-memoized MockLLM model with the given callable.""" + return get_model(model_name, custom_outputs=custom_outputs, memoize=False) diff --git a/tests/_helpers/strategies.py b/tests/_helpers/strategies.py new file mode 100644 index 0000000000..e8597f0d52 --- /dev/null +++ b/tests/_helpers/strategies.py @@ -0,0 +1,67 @@ +"""Hypothesis strategies for retry-timing property tests.""" + +# pyright: reportAny=false, reportExplicitAny=false, reportUnnecessaryCast=false + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal, cast + +import pytest + +st = cast(Any, pytest.importorskip("hypothesis").strategies) + +AttemptOutcome = Literal["success", "fail-retryable", "fail-permanent", "cache"] +CacheState = Literal["hit", "miss-then-write", "miss-then-fail"] + + +@dataclass(frozen=True) +class RetryScenario: + """Generated retry-attempt scenario.""" + + outcomes: list[AttemptOutcome] + max_retries: int + + +@dataclass(frozen=True) +class CacheScenario: + """Generated cache scenario.""" + + state: CacheState + pre_call_failures: int = 0 + + +def attempt_outcome_sequences(*, min_attempts: int = 1, max_attempts: int = 10) -> Any: + """Generate retryable prefixes ending in one terminal outcome.""" + + def build(prefix_count: int, terminal: AttemptOutcome) -> list[AttemptOutcome]: + retryable: AttemptOutcome = "fail-retryable" + outcomes: list[AttemptOutcome] = [retryable for _ in range(prefix_count)] + outcomes.append(terminal) + return outcomes + + return st.builds( + build, + prefix_count=st.integers( + min_value=min_attempts - 1, max_value=max_attempts - 1 + ), + terminal=st.sampled_from(["success", "fail-permanent", "cache"]), + ) + + +def retry_scenarios() -> Any: + """Generate retry scenarios with bounded attempt counts.""" + return st.builds( + RetryScenario, + outcomes=attempt_outcome_sequences(min_attempts=1, max_attempts=8), + max_retries=st.integers(min_value=1, max_value=10), + ) + + +def cache_scenarios() -> Any: + """Generate cache states for property tests.""" + return st.builds( + CacheScenario, + state=st.sampled_from(["hit", "miss-then-write", "miss-then-fail"]), + pre_call_failures=st.integers(min_value=0, max_value=5), + ) diff --git a/tests/_helpers/test_event_assertions.py b/tests/_helpers/test_event_assertions.py new file mode 100644 index 0000000000..3eca2f4940 --- /dev/null +++ b/tests/_helpers/test_event_assertions.py @@ -0,0 +1,108 @@ +"""Tests for event-assertion helpers.""" + +# pyright: reportImplicitRelativeImport=false + +from datetime import datetime, timedelta, timezone +from typing import Literal + +import pytest + +from _helpers.event_assertions import ( + assert_attempt_group, + assert_call_field_invariants, + assert_no_legacy_rewrite, +) +from inspect_ai.event._model import ModelEvent +from inspect_ai.model._chat_message import ChatMessageUser +from inspect_ai.model._generate_config import GenerateConfig +from inspect_ai.model._model_output import ModelOutput + + +def _ev( + *, + call_id: str | None, + attempt: int | None, + ts_offset_seconds: float = 0, + working_start: float = 0.0, + working_time: float = 0.5, + error: str | None = None, + cache: Literal["read", "write"] | None = None, + call_started_at: datetime | None = None, + call_completed_at: datetime | None = None, + call_working_start: float | None = None, + call_working_time: float | None = None, + call_retries: int | None = None, + http_retries: int | None = None, +) -> ModelEvent: + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + return ModelEvent( + model="fake", + input=[ChatMessageUser(content="x")], + tools=[], + tool_choice="auto", + config=GenerateConfig(), + output=ModelOutput.from_content("fake", "ok"), + call_id=call_id, + attempt=attempt, + timestamp=base + timedelta(seconds=ts_offset_seconds), + working_start=working_start, + working_time=working_time, + completed=base + timedelta(seconds=ts_offset_seconds + working_time), + error=error, + cache=cache, + call_started_at=call_started_at, + call_completed_at=call_completed_at, + call_working_start=call_working_start, + call_working_time=call_working_time, + call_retries=call_retries, + http_retries=http_retries, + ) + + +def test_assert_attempt_group_passes_for_success_sequence() -> None: + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + events = [ + _ev(call_id="abc", attempt=1, ts_offset_seconds=0, error="boom"), + _ev(call_id="abc", attempt=2, ts_offset_seconds=1, error="boom"), + _ev( + call_id="abc", + attempt=3, + ts_offset_seconds=2, + call_started_at=base, + call_completed_at=base + timedelta(seconds=2.5), + call_working_start=0.0, + call_working_time=1.5, + call_retries=2, + http_retries=2, + ), + ] + assert_attempt_group(events, retries=2, terminal_kind="success") + + +def test_assert_no_legacy_rewrite_flags_inversion() -> None: + events = [ + _ev(call_id="abc", attempt=1, ts_offset_seconds=10, error="boom"), + _ev(call_id="abc", attempt=2, ts_offset_seconds=0), + ] + with pytest.raises(AssertionError, match="timestamp inversion"): + assert_no_legacy_rewrite(events) + + +def test_assert_call_field_invariants_terminal_success() -> None: + base = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + ev = _ev( + call_id="x", + attempt=1, + call_started_at=base, + call_completed_at=base + timedelta(seconds=1), + call_working_start=0.0, + call_working_time=0.5, + call_retries=0, + http_retries=0, + ) + assert_call_field_invariants(ev, kind="terminal-success") + + +def test_assert_call_field_invariants_non_terminal_no_call_fields() -> None: + ev = _ev(call_id="x", attempt=1, error="boom") + assert_call_field_invariants(ev, kind="non-terminal") diff --git a/tests/_helpers/test_retry_provider.py b/tests/_helpers/test_retry_provider.py new file mode 100644 index 0000000000..bf2a53142b --- /dev/null +++ b/tests/_helpers/test_retry_provider.py @@ -0,0 +1,55 @@ +"""Tests for retry-test provider helpers.""" + +# pyright: reportImplicitRelativeImport=false + +import pytest +from tenacity import wait_fixed + +from _helpers.retry_provider import ( + RaisingThenSucceedingAPI, + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) +from inspect_ai.model import GenerateConfig +from inspect_ai.model._chat_message import ChatMessageUser +from inspect_ai.model._model_output import ModelOutput + + +async def test_raising_then_succeeding_api() -> None: + api = RaisingThenSucceedingAPI(failures=2) + for _ in range(2): + with pytest.raises(RetryableModelError): + await api.generate( + [ChatMessageUser(content="x")], [], "auto", GenerateConfig() + ) + out = await api.generate( + [ChatMessageUser(content="x")], [], "auto", GenerateConfig() + ) + assert (out[0].completion if isinstance(out, tuple) else out.completion) == "ok" + + +async def test_retry_wait_returns_zero() -> None: + api = RaisingThenSucceedingAPI(failures=0) + assert isinstance(api.retry_wait(), type(wait_fixed(0))) + + +async def test_install_retry_classifier_classifies_retryable_error() -> None: + model = make_mockllm_with_callable( + lambda *args, **kwargs: ModelOutput.from_content("mockllm", "ok") + ) + install_retry_classifier(model) + assert model.api.should_retry(RetryableModelError("x")) is True + assert model.api.should_retry(ValueError("x")) is False + + +async def test_make_mockllm_with_callable_invokes_callable_per_generate() -> None: + calls: list[object] = [] + + def custom_outputs(input, tools, tool_choice, config): + calls.append(input) + return ModelOutput.from_content("mockllm", f"call-{len(calls)}") + + model = make_mockllm_with_callable(custom_outputs) + assert (await model.generate("a")).completion == "call-1" + assert (await model.generate("b")).completion == "call-2" diff --git a/tests/analysis/test_model_event_dataframe_export.py b/tests/analysis/test_model_event_dataframe_export.py new file mode 100644 index 0000000000..95104b0f0f --- /dev/null +++ b/tests/analysis/test_model_event_dataframe_export.py @@ -0,0 +1,53 @@ +"""Dataframe export populates new model-event retry columns.""" + +# pyright: reportImplicitRelativeImport=false + +from _helpers.retry_provider import ( + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) +from test_helpers.utils import skip_if_trio + +from inspect_ai import Task, eval_async +from inspect_ai.analysis import events_df +from inspect_ai.analysis._dataframe.events.columns import EventInfo, ModelEventColumns +from inspect_ai.dataset import Sample +from inspect_ai.model._model_output import ModelOutput +from inspect_ai.solver import generate + + +@skip_if_trio +async def test_dataframe_includes_call_id_and_attempt_for_retry() -> None: + remaining = [2] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + return ModelOutput.from_content("mockllm", "ok") + + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + logs = await eval_async( + Task(dataset=[Sample(input="hello")], solver=generate()), + model=model, + ) + + df = events_df( + logs[0], + columns=EventInfo + ModelEventColumns, + filter=lambda event: event.event == "model", + ) + model_rows = df[df["event"] == "model"] + assert len(model_rows) == 3 + assert model_rows["model_event_call_id"].nunique() == 1 + assert sorted(model_rows["model_event_attempt"].tolist()) == [1, 2, 3] + + terminal = model_rows.iloc[-1] + assert terminal["model_event_call_retries"] == 2 + assert terminal["model_event_http_retries"] == 2 + assert terminal["model_event_call_working_time"] >= 0 + + earlier = model_rows.iloc[:-1] + assert earlier["model_event_call_retries"].isna().all() diff --git a/tests/log/test_legacy_log_compat.py b/tests/log/test_legacy_log_compat.py new file mode 100644 index 0000000000..65072c1b98 --- /dev/null +++ b/tests/log/test_legacy_log_compat.py @@ -0,0 +1,53 @@ +"""Legacy log compatibility and format detection via call_id sentinel.""" + +# pyright: reportImplicitRelativeImport=false + +from pathlib import Path + +from _helpers.event_assertions import model_events + +from inspect_ai.log import read_eval_log + +FIXTURE_DIR = Path(__file__).parent.parent / "_helpers" / "fixtures" +LEGACY = FIXTURE_DIR / "legacy" / "retry-2-then-success.eval" +POSTFIX = FIXTURE_DIR / "postfix" / "retry-2-then-success.eval" + + +def test_legacy_fixture_reads_without_crash() -> None: + log = read_eval_log(str(LEGACY)) + assert log.samples + + +def test_legacy_fixture_has_no_call_id() -> None: + log = read_eval_log(str(LEGACY)) + assert log.samples is not None + events = model_events(log.samples[0]) + assert events + assert all(event.call_id is None for event in events) + assert all(event.attempt is None for event in events) + + +def test_legacy_fixture_preserves_rewrite_signature() -> None: + log = read_eval_log(str(LEGACY)) + assert log.samples is not None + events = model_events(log.samples[0]) + success = events[-1] + failed = events[:-1] + assert any(success.timestamp < event.timestamp for event in failed) + + +def test_postfix_fixture_has_call_id_and_attempt() -> None: + log = read_eval_log(str(POSTFIX)) + assert log.samples is not None + events = model_events(log.samples[0]) + assert events + assert all(event.call_id is not None for event in events) + assert [event.attempt for event in events] == [1, 2, 3] + + +def test_postfix_fixture_timestamps_non_decreasing() -> None: + log = read_eval_log(str(POSTFIX)) + assert log.samples is not None + events = model_events(log.samples[0]) + for previous, current in zip(events, events[1:]): + assert current.timestamp >= previous.timestamp diff --git a/tests/model/test_adaptive_controller_retry_signals.py b/tests/model/test_adaptive_controller_retry_signals.py new file mode 100644 index 0000000000..bcd11e8b64 --- /dev/null +++ b/tests/model/test_adaptive_controller_retry_signals.py @@ -0,0 +1,52 @@ +"""Adaptive-affecting changes from the retry-timing fix.""" + +# pyright: reportImplicitRelativeImport=false + +import time +from datetime import datetime, timezone + +from _helpers.retry_provider import RaisingThenSucceedingAPI + +from inspect_ai._util.working import init_sample_working_time, sample_waiting_time +from inspect_ai.model import get_model +from inspect_ai.model._generate_accounting import ( + ModelGenerateAccounting, + current_model_generate_accounting, + model_generate_accounting, +) + + +async def test_negative_waiting_delta_does_not_propagate_to_sample_waiting() -> None: + init_sample_working_time(time.monotonic()) + model = get_model("mockllm/test", memoize=False) + model.api = RaisingThenSucceedingAPI( + failures=0, + success_output_time=1000.0, + ) + + await model.generate("hello") + + assert sample_waiting_time() >= 0 + + +async def test_report_http_retry_with_no_active_accounting_does_not_raise() -> None: + from inspect_ai._util.retry import http_retries_count, report_http_retry + + assert current_model_generate_accounting() is None + before = http_retries_count() + report_http_retry() + assert http_retries_count() == before + 1 + + +async def test_report_http_retry_with_active_accounting_increments_counter() -> None: + from inspect_ai._util.retry import report_http_retry + + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + working_start=0.0, + ) + async with model_generate_accounting(acc): + report_http_retry() + report_http_retry() + + assert acc.http_retry_count == 2 diff --git a/tests/model/test_count_tokens_compact_retry.py b/tests/model/test_count_tokens_compact_retry.py new file mode 100644 index 0000000000..4b41415fe7 --- /dev/null +++ b/tests/model/test_count_tokens_compact_retry.py @@ -0,0 +1,121 @@ +"""count_tokens and compact: eventless contract plus resolved retry config.""" + +# pyright: reportImplicitRelativeImport=false + +from _helpers.event_assertions import model_events +from _helpers.retry_provider import RetryableModelError +from tenacity import wait_fixed +from tenacity.wait import WaitBaseT + +from inspect_ai._util.registry import REGISTRY_INFO, RegistryInfo +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig, get_model +from inspect_ai.model._chat_message import ChatMessage +from inspect_ai.model._model import ModelAPI +from inspect_ai.model._model_call import ModelCall +from inspect_ai.model._model_output import ModelOutput, ModelUsage +from inspect_ai.tool import ToolChoice, ToolInfo + + +class _CountingAPI(ModelAPI): + def __init__(self, *, count_failures: int = 0, compact_failures: int = 0) -> None: + super().__init__( + model_name="fake", + base_url=None, + api_key=None, + api_key_vars=[], + config=GenerateConfig(), + ) + setattr( + self, + REGISTRY_INFO, + RegistryInfo(type="modelapi", name="inspect_ai/mockllm"), + ) + self.count_attempts = 0 + self.compact_attempts = 0 + self._count_failures = count_failures + self._compact_failures = compact_failures + + async def generate( + self, + input: list[ChatMessage], + tools: list[ToolInfo], + tool_choice: ToolChoice, + config: GenerateConfig, + ) -> ModelOutput | tuple[ModelOutput, ModelCall]: + return ModelOutput.from_content("fake", "ok") + + async def count_tokens( + self, input: str | list[ChatMessage], config: GenerateConfig | None = None + ) -> int: + self.count_attempts += 1 + if self.count_attempts <= self._count_failures: + raise RetryableModelError("transient") + return 42 + + async def compact( + self, + input: list[ChatMessage], + tools: list[ToolInfo], + config: GenerateConfig, + instructions: str | None = None, + ) -> tuple[list[ChatMessage], ModelUsage | None]: + self.compact_attempts += 1 + if self.compact_attempts <= self._compact_failures: + raise RetryableModelError("transient") + return input, ModelUsage(input_tokens=1, output_tokens=1, total_tokens=2) + + def should_retry(self, ex: Exception) -> bool: + return isinstance(ex, RetryableModelError) + + def retry_wait(self) -> WaitBaseT: + return wait_fixed(0) + + +async def test_count_tokens_emits_no_model_events() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = _CountingAPI(count_failures=2) + + result = await model.count_tokens("hi", GenerateConfig(max_retries=5)) + + assert result == 42 + assert model_events(transcript) == [] + + +async def test_count_tokens_honors_local_config_max_retries() -> None: + model = get_model("mockllm/test", memoize=False) + api = _CountingAPI(count_failures=2) + model.api = api + model.config = GenerateConfig(max_retries=1) + + result = await model.count_tokens("hi", GenerateConfig(max_retries=5)) + + assert result == 42 + assert api.count_attempts == 3 + + +async def test_compact_emits_no_model_events() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = _CountingAPI(compact_failures=1) + model.config = GenerateConfig(max_retries=3) + + _, usage = await model.compact([], tools=[]) + + assert usage is not None + assert model_events(transcript) == [] + + +async def test_compact_uses_resolved_config_retry_settings() -> None: + model = get_model("mockllm/test", memoize=False) + api = _CountingAPI(compact_failures=2) + model.api = api + model.config = GenerateConfig(max_retries=5) + + _, usage = await model.compact([], tools=[]) + + assert usage is not None + assert api.compact_attempts == 3 diff --git a/tests/model/test_generate_accounting.py b/tests/model/test_generate_accounting.py new file mode 100644 index 0000000000..4e0ae09b07 --- /dev/null +++ b/tests/model/test_generate_accounting.py @@ -0,0 +1,154 @@ +"""Unit tests for ModelGenerateAccounting.""" + +from datetime import datetime, timedelta, timezone + +import anyio + +from inspect_ai.event._model import ModelEvent +from inspect_ai.model._chat_message import ChatMessageUser +from inspect_ai.model._generate_accounting import ( + ModelGenerateAccounting, + current_model_generate_accounting, + model_generate_accounting, +) +from inspect_ai.model._generate_config import GenerateConfig +from inspect_ai.model._model_output import ModelOutput + + +def _empty_event(call_id: str, attempt: int) -> ModelEvent: + return ModelEvent( + model="fake", + input=[ChatMessageUser(content="x")], + tools=[], + tool_choice="auto", + config=GenerateConfig(), + output=ModelOutput.from_content("fake", "ok"), + call_id=call_id, + attempt=attempt, + ) + + +def test_new_accounting_has_call_id_zero_counters_no_last_event() -> None: + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + assert acc.call_id + assert acc.attempt_count == 0 + assert acc.call_retry_count == 0 + assert acc.http_retry_count == 0 + assert acc.last_event is None + + +def test_register_event_stamps_call_id_and_attempt() -> None: + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + ev1 = _empty_event(call_id="", attempt=0) + ev1.call_id = None + ev1.attempt = None + acc.register_event(ev1) + assert ev1.call_id == acc.call_id + assert ev1.attempt == 1 + assert acc.last_event is ev1 + + ev2 = _empty_event(call_id="", attempt=0) + ev2.call_id = None + ev2.attempt = None + acc.register_event(ev2) + assert ev2.attempt == 2 + assert acc.last_event is ev2 + + +def test_retry_counters_are_independent() -> None: + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + acc.record_call_retry() + acc.record_call_retry() + acc.record_http_retry() + assert acc.call_retry_count == 2 + assert acc.http_retry_count == 1 + + +def test_finalize_terminal_event_sets_call_fields_and_legacy_retries() -> None: + start = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + acc = ModelGenerateAccounting.new(started_at=start, working_start=0.0) + ev = _empty_event(call_id=acc.call_id, attempt=2) + acc.last_event = ev + acc.record_call_retry() + acc.record_http_retry() + acc.record_http_retry() + + end = start + timedelta(seconds=3.0) + acc.finalize_terminal_event(event=ev, completed_at=end, working_now=2.5) + + assert ev.call_started_at == start + assert ev.call_completed_at == end + assert ev.call_working_start == 0.0 + assert ev.call_working_time == 2.5 + assert ev.call_retries == 1 + assert ev.http_retries == 2 + assert ev.retries == 2 + + +def test_finalize_terminal_event_is_idempotent() -> None: + start = datetime(2026, 1, 1, tzinfo=timezone.utc) + acc = ModelGenerateAccounting.new(started_at=start, working_start=0.0) + ev = _empty_event(call_id=acc.call_id, attempt=1) + acc.finalize_terminal_event(event=ev, completed_at=start, working_now=1.0) + original_completed = ev.call_completed_at + acc.record_call_retry() + acc.finalize_terminal_event( + event=ev, completed_at=start + timedelta(seconds=10), working_now=99.0 + ) + assert ev.call_completed_at == original_completed + assert ev.call_retries == 0 + + +async def test_context_manager_sets_and_clears() -> None: + assert current_model_generate_accounting() is None + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + async with model_generate_accounting(acc): + assert current_model_generate_accounting() is acc + assert current_model_generate_accounting() is None + + +async def test_re_entrant_restores_outer() -> None: + outer = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + inner = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), working_start=0.0 + ) + async with model_generate_accounting(outer): + async with model_generate_accounting(inner): + assert current_model_generate_accounting() is inner + assert current_model_generate_accounting() is outer + + +async def test_concurrent_tasks_have_independent_accounting() -> None: + results: list[str | None] = [] + + async def task(date: datetime) -> None: + acc = ModelGenerateAccounting.new(started_at=date, working_start=0.0) + async with model_generate_accounting(acc): + await anyio.sleep(0.01) + cur = current_model_generate_accounting() + results.append(cur.call_id if cur else None) + + async with anyio.create_task_group() as tg: + tg.start_soon(task, datetime(2026, 1, 1, tzinfo=timezone.utc)) + tg.start_soon(task, datetime(2026, 1, 2, tzinfo=timezone.utc)) + assert len(results) == 2 + assert results[0] != results[1] + + +def test_legacy_retries_mirrors_call_retries_when_no_http() -> None: + start = datetime(2026, 1, 1, tzinfo=timezone.utc) + acc = ModelGenerateAccounting.new(started_at=start, working_start=0.0) + ev = _empty_event(call_id=acc.call_id, attempt=2) + acc.record_call_retry() + acc.finalize_terminal_event(event=ev, completed_at=start, working_now=0.0) + assert ev.retries == 1 diff --git a/tests/model/test_model_event_attempt_timeout.py b/tests/model/test_model_event_attempt_timeout.py new file mode 100644 index 0000000000..eedd6d987f --- /dev/null +++ b/tests/model/test_model_event_attempt_timeout.py @@ -0,0 +1,52 @@ +"""ModelEvent timing for attempt_timeout retry paths.""" + +# pyright: reportImplicitRelativeImport=false + +from _helpers.event_assertions import assert_attempt_group, model_events +from _helpers.retry_provider import SlowThenSuccessAPI +from tenacity import RetryError + +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig, get_model + + +async def test_attempt_timeout_retry_then_success_has_per_attempt_timing() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = SlowThenSuccessAPI(slow_seconds=2.0) + + await model.generate( + "hello", config=GenerateConfig(attempt_timeout=1, max_retries=3) + ) + + events = model_events(transcript) + assert len(events) == 2 + assert events[0].error is not None + assert events[0].completed is not None + assert events[0].working_time is not None + assert_attempt_group(events, retries=1, terminal_kind="success") + + +async def test_exhausted_attempt_timeouts_finalize_terminal_event() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = SlowThenSuccessAPI(slow_seconds=2.0) + + try: + await model.generate( + "hello", config=GenerateConfig(attempt_timeout=1, max_retries=0) + ) + except RetryError: + pass + + events = model_events(transcript) + # max_retries=0 permits no retries (a single attempt), so the timeout + # exhausts the retry budget immediately + assert len(events) == 1 + assert events[0].error is not None + assert events[0].completed is not None + assert events[0].working_time is not None + assert events[0].call_started_at is not None + assert events[0].call_completed_at is not None diff --git a/tests/model/test_model_event_call_grouping.py b/tests/model/test_model_event_call_grouping.py new file mode 100644 index 0000000000..bd1369268e --- /dev/null +++ b/tests/model/test_model_event_call_grouping.py @@ -0,0 +1,92 @@ +"""call_id and attempt invariants for ModelEvent retry groups.""" + +# pyright: reportImplicitRelativeImport=false + +import anyio +from _helpers.event_assertions import assert_no_legacy_rewrite, model_events +from _helpers.retry_provider import ( + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) + +from inspect_ai.log._transcript import Transcript, init_transcript + + +async def test_single_success_has_call_id_and_attempt_1() -> None: + def custom_outputs(input, tools, tool_choice, config): + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + await model.generate("hello") + + events = model_events(transcript) + assert len(events) == 1 + assert events[0].call_id + assert events[0].attempt == 1 + + +async def test_retry_events_share_call_id_with_contiguous_attempts() -> None: + remaining = [2] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + await model.generate("hello") + + events = model_events(transcript) + assert len(events) == 3 + assert len({event.call_id for event in events}) == 1 + assert [event.attempt for event in events] == [1, 2, 3] + + +async def test_consecutive_generates_have_distinct_call_ids() -> None: + def custom_outputs(input, tools, tool_choice, config): + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + await model.generate("first") + await model.generate("second") + + events = model_events(transcript) + assert len(events) == 2 + assert events[0].call_id != events[1].call_id + assert events[0].attempt == 1 + assert events[1].attempt == 1 + + +async def test_concurrent_generates_have_independent_call_ids() -> None: + def custom_outputs(input, tools, tool_choice, config): + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + async with anyio.create_task_group() as tg: + tg.start_soon(model.generate, "a") + tg.start_soon(model.generate, "b") + + events = model_events(transcript) + assert len(events) == 2 + assert len({event.call_id for event in events}) == 2 + assert all(event.attempt == 1 for event in events) + assert_no_legacy_rewrite(events) diff --git a/tests/model/test_model_event_provider_returned_exception.py b/tests/model/test_model_event_provider_returned_exception.py new file mode 100644 index 0000000000..fee9bee200 --- /dev/null +++ b/tests/model/test_model_event_provider_returned_exception.py @@ -0,0 +1,77 @@ +"""Provider returning an Exception object: RuntimeError wrap, no retry.""" + +# pyright: reportImplicitRelativeImport=false + +import pytest +from _helpers.event_assertions import model_events +from _helpers.retry_provider import RetryableModelError, install_retry_classifier +from tenacity import wait_fixed +from tenacity.wait import WaitBaseT + +from inspect_ai._util.registry import REGISTRY_INFO, RegistryInfo +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig, get_model +from inspect_ai.model._chat_message import ChatMessage +from inspect_ai.model._model import ModelAPI +from inspect_ai.model._model_call import ModelCall +from inspect_ai.model._model_output import ModelOutput +from inspect_ai.tool import ToolChoice, ToolInfo + + +class _ReturningExceptionAPI(ModelAPI): + def __init__(self, *, attempts_to_return_exception: int) -> None: + super().__init__( + model_name="fake", + base_url=None, + api_key=None, + api_key_vars=[], + config=GenerateConfig(), + ) + setattr( + self, + REGISTRY_INFO, + RegistryInfo(type="modelapi", name="inspect_ai/mockllm"), + ) + self._remaining = attempts_to_return_exception + + async def generate( + self, + input: list[ChatMessage], + tools: list[ToolInfo], + tool_choice: ToolChoice, + config: GenerateConfig, + ) -> ModelOutput | tuple[ModelOutput | Exception, ModelCall]: + if self._remaining > 0: + self._remaining -= 1 + call = ModelCall.create( + {"input": [message.model_dump() for message in input]}, None + ) + return RetryableModelError("transient"), call + return ModelOutput.from_content("fake", "ok") + + def should_retry(self, ex: Exception) -> bool: + return isinstance(ex, RetryableModelError) + + def retry_wait(self) -> WaitBaseT: + return wait_fixed(0) + + +async def test_returned_exception_raises_runtime_error_no_retry() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = _ReturningExceptionAPI(attempts_to_return_exception=1) + install_retry_classifier(model) + + with pytest.raises(RuntimeError) as exc_info: + await model.generate("hello", config=GenerateConfig(max_retries=5)) + + assert "RetryableModelError" in str(exc_info.value) + events = model_events(transcript) + assert len(events) == 1 + event = events[0] + assert event.error is not None + assert event.completed is not None + assert event.working_time is not None + assert event.call_started_at is not None + assert event.call_completed_at is not None diff --git a/tests/model/test_model_event_retry_timing.py b/tests/model/test_model_event_retry_timing.py new file mode 100644 index 0000000000..62a6c8522a --- /dev/null +++ b/tests/model/test_model_event_retry_timing.py @@ -0,0 +1,103 @@ +"""Comprehensive retry-timing scenarios.""" + +# pyright: reportImplicitRelativeImport=false + +import anyio +import pytest +from _helpers.event_assertions import ( + assert_attempt_group, + assert_no_legacy_rewrite, + model_events, +) +from _helpers.retry_provider import ( + RetryableModelError, + SlowThenSuccessAPI, + install_retry_classifier, + make_mockllm_with_callable, +) +from tenacity import RetryError + +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig, get_model + + +async def _retry_then_success(failures: int) -> Transcript: + remaining = [failures] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + await model.generate("hello", config=GenerateConfig(max_retries=failures + 5)) + return transcript + + +@pytest.mark.parametrize("failures", [1, 2, 5]) +async def test_n_retries_then_success(failures: int) -> None: + transcript = await _retry_then_success(failures) + events = model_events(transcript) + assert len(events) == failures + 1 + assert_attempt_group(events, retries=failures, terminal_kind="success") + + +async def test_terminal_event_call_working_time_le_wall_duration() -> None: + transcript = await _retry_then_success(2) + terminal = model_events(transcript)[-1] + assert terminal.call_completed_at is not None + assert terminal.call_started_at is not None + assert terminal.call_working_time is not None + wall = (terminal.call_completed_at - terminal.call_started_at).total_seconds() + assert terminal.call_working_time <= wall + 1e-6 + + +async def test_exhausted_retries_emit_terminal_failure_event() -> None: + def always_fail(input, tools, tool_choice, config): + raise RetryableModelError("persistent") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(always_fail) + install_retry_classifier(model) + with pytest.raises(RetryError): + await model.generate("hello", config=GenerateConfig(max_retries=3)) + + events = model_events(transcript) + # max_retries=3 permits 3 retries (4 total attempts) + assert len(events) == 4 + assert_attempt_group(events, retries=3, terminal_kind="exhausted") + assert_no_legacy_rewrite(events) + + +async def test_cancellation_mid_attempt_emits_terminal_failure_event() -> None: + transcript = Transcript() + init_transcript(transcript) + model = get_model("mockllm/test", memoize=False) + model.api = SlowThenSuccessAPI(slow_seconds=10.0) + + async with anyio.create_task_group() as tg: + + async def run() -> None: + try: + await model.generate("hello") + except BaseException: + pass + + tg.start_soon(run) + await anyio.sleep(0.01) + tg.cancel_scope.cancel() + + events = model_events(transcript) + assert len(events) == 1 + event = events[0] + assert event.error is not None + assert event.completed is not None + assert event.call_started_at is not None + assert event.call_completed_at is not None diff --git a/tests/model/test_model_event_terminal_finalization.py b/tests/model/test_model_event_terminal_finalization.py new file mode 100644 index 0000000000..4f8d9baf0a --- /dev/null +++ b/tests/model/test_model_event_terminal_finalization.py @@ -0,0 +1,76 @@ +"""Per-attempt completed/working_time set on every event outcome.""" + +# pyright: reportImplicitRelativeImport=false + +import pytest +from _helpers.event_assertions import model_events +from _helpers.retry_provider import ( + RaisingThenSucceedingAPI, + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) +from tenacity import RetryError + +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig, get_model + + +async def test_success_event_has_completed_and_working_time() -> None: + def custom_outputs(input, tools, tool_choice, config): + from inspect_ai.model._model_output import ModelOutput + + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + await model.generate("hello") + + events = model_events(transcript) + assert len(events) == 1 + assert events[0].completed is not None + assert events[0].working_time is not None + assert events[0].working_time >= 0 + + +async def test_failed_attempts_have_completed_and_working_time() -> None: + def always_fail(input, tools, tool_choice, config): + raise RetryableModelError("boom") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(always_fail) + install_retry_classifier(model) + with pytest.raises(RetryError): + await model.generate("hello", config=GenerateConfig(max_retries=2)) + + events = model_events(transcript) + # max_retries=2 permits 2 retries (3 total attempts) + assert len(events) == 3 + for event in events: + assert event.error is not None + assert event.completed is not None + assert event.working_time is not None + assert event.working_time >= 0 + + +async def test_cache_read_event_has_per_attempt_completed_and_working_time() -> None: + model = get_model("mockllm/test", memoize=False) + model.api = RaisingThenSucceedingAPI( + failures=0, + success_output_time=5.0, + ) + await model.generate("hello", cache=True) + + transcript = Transcript() + init_transcript(transcript) + await model.generate("hello", cache=True) + + events = model_events(transcript) + assert len(events) == 1 + event = events[0] + assert event.cache == "read" + assert event.completed is not None + assert event.working_time is not None + assert event.working_time < 5.0 diff --git a/tests/model/test_provider_retry_classification.py b/tests/model/test_provider_retry_classification.py new file mode 100644 index 0000000000..3cfe6463ba --- /dev/null +++ b/tests/model/test_provider_retry_classification.py @@ -0,0 +1,118 @@ +"""Distinguish outer Tenacity retries from provider-internal retry signals.""" + +# pyright: reportImplicitRelativeImport=false + +from collections.abc import Generator + +from _helpers.event_assertions import model_events +from _helpers.retry_provider import ( + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) + +from inspect_ai._util.registry import _registry +from inspect_ai.hooks._hooks import Hooks, ModelUsageData, hooks +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig +from inspect_ai.model._model_output import ModelOutput, ModelUsage + + +class _UsageHooks(Hooks): + def __init__(self) -> None: + self.model_usage_events: list[ModelUsageData] = [] + + async def on_model_usage(self, data: ModelUsageData) -> None: + self.model_usage_events.append(data) + + +def _registered_usage_hooks() -> Generator[_UsageHooks, None, None]: + name = "test_retry_classification_usage" + + @hooks(name, description="capture usage retry counters") + def usage_hooks() -> type[_UsageHooks]: + return _UsageHooks + + hook = _registry[f"hooks:{name}"] + assert isinstance(hook, _UsageHooks) + try: + yield hook + finally: + del _registry[f"hooks:{name}"] + + +async def test_outer_retry_increments_call_and_http_counters() -> None: + remaining = [2] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + output = ModelOutput.from_content("mockllm", "ok") + output.usage = ModelUsage(input_tokens=1, output_tokens=1, total_tokens=2) + return output + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + + await model.generate("hello", config=GenerateConfig(max_retries=5)) + + terminal = model_events(transcript)[-1] + assert terminal.call_retries == 2 + assert terminal.http_retries == 2 + assert terminal.retries == 2 + + +async def test_provider_internal_retry_increments_only_http_counter() -> None: + def custom_outputs(input, tools, tool_choice, config): + from inspect_ai._util.retry import report_http_retry + + report_http_retry() + output = ModelOutput.from_content("mockllm", "ok") + output.usage = ModelUsage(input_tokens=1, output_tokens=1, total_tokens=2) + return output + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + + await model.generate("hello") + + terminal = model_events(transcript)[-1] + assert terminal.call_retries == 0 + assert terminal.http_retries == 1 + assert terminal.retries == 1 + + +async def test_model_usage_hook_receives_retry_counters() -> None: + remaining = [1] + hooks_iter = _registered_usage_hooks() + usage_hooks = next(hooks_iter) + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + output = ModelOutput.from_content("mockllm", "ok") + output.usage = ModelUsage(input_tokens=1, output_tokens=1, total_tokens=2) + return output + + try: + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + + await model.generate("hello", config=GenerateConfig(max_retries=3)) + + assert len(usage_hooks.model_usage_events) == 1 + usage = usage_hooks.model_usage_events[0] + assert usage.call_retries == 1 + assert usage.http_retries == 1 + assert usage.call_working_time is not None + assert usage.call_working_time >= 0 + finally: + try: + next(hooks_iter) + except StopIteration: + pass diff --git a/tests/model/test_sample_retry_model_retry_interaction.py b/tests/model/test_sample_retry_model_retry_interaction.py new file mode 100644 index 0000000000..e917a6d39f --- /dev/null +++ b/tests/model/test_sample_retry_model_retry_interaction.py @@ -0,0 +1,59 @@ +"""Sample-level retry_on_error composed with model-level retries.""" + +# pyright: reportImplicitRelativeImport=false + +from _helpers.event_assertions import model_events +from _helpers.retry_provider import ( + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) + +from inspect_ai import Task +from inspect_ai import eval as inspect_eval +from inspect_ai.dataset import Sample +from inspect_ai.model._model_output import ModelOutput +from inspect_ai.solver import Generate, TaskState, solver + + +@solver +def _model_retry_then_solver_fail(should_fail: list[bool]): + async def solve(state: TaskState, generate: Generate) -> TaskState: + state = await generate(state) + if should_fail.pop(0): + raise ValueError("solver-level failure") + return state + + return solve + + +def test_model_retries_inside_sample_retry_have_distinct_call_groups() -> None: + remaining_model_failures = [2] + + def custom_outputs(input, tools, tool_choice, config): + if remaining_model_failures[0] > 0: + remaining_model_failures[0] -= 1 + raise RetryableModelError("transient") + return ModelOutput.from_content("mockllm", "ok") + + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + log = inspect_eval( + Task( + dataset=[Sample(input="hello")], + solver=_model_retry_then_solver_fail([True, False]), + ), + model=model, + retry_on_error=1, + )[0] + + assert log.samples is not None + sample = log.samples[0] + assert sample.error_retries is not None + first_attempt_events = model_events(sample.error_retries[0]) + second_attempt_events = model_events(sample) + + assert [event.attempt for event in first_attempt_events] == [3] + assert first_attempt_events[0].call_retries == 2 + assert [event.attempt for event in second_attempt_events] == [1] + assert first_attempt_events[0].call_id != second_attempt_events[0].call_id diff --git a/tests/model/test_tool_subtask_event_smoke.py b/tests/model/test_tool_subtask_event_smoke.py new file mode 100644 index 0000000000..cf538b6ec0 --- /dev/null +++ b/tests/model/test_tool_subtask_event_smoke.py @@ -0,0 +1,81 @@ +"""Smoke regression: adjacent event timing semantics remain intact.""" + +from inspect_ai import Task +from inspect_ai import eval as inspect_eval +from inspect_ai.dataset import Sample +from inspect_ai.event._subtask import SubtaskEvent +from inspect_ai.event._tool import ToolEvent +from inspect_ai.model._model_output import ModelOutput +from inspect_ai.solver import generate, use_tools +from inspect_ai.tool import tool + + +@tool +def slow_tool(): + """Double an integer slowly.""" + + async def execute(x: int) -> int: + """Double an integer slowly. + + Args: + x: Integer to double. + """ + import anyio + + await anyio.sleep(0.01) + return x * 2 + + return execute + + +def test_tool_event_working_time_set_and_nonnegative() -> None: + calls = [0] + + def custom_outputs( + _input: object, _tools: object, _tool_choice: object, _config: object + ) -> ModelOutput: + calls[0] += 1 + if calls[0] == 1: + return ModelOutput.for_tool_call("mockllm", "slow_tool", {"x": 2}) + return ModelOutput.from_content("mockllm", "done") + + log = inspect_eval( + Task(dataset=[Sample(input="hi")], solver=[use_tools(slow_tool()), generate()]), + model="mockllm/test", + model_args={"custom_outputs": custom_outputs}, + )[0] + + assert log.samples is not None + tool_events = [ + event for event in log.samples[0].events if isinstance(event, ToolEvent) + ] + assert tool_events + for event in tool_events: + assert event.completed is not None + assert event.working_time is not None + assert event.working_time >= 0 + + +async def test_subtask_event_working_time_set_and_nonnegative() -> None: + from inspect_ai.log._transcript import Transcript, init_transcript + from inspect_ai.util import subtask + + @subtask + async def my_subtask(x: int) -> int: + import anyio + + await anyio.sleep(0.01) + return x + + transcript = Transcript() + init_transcript(transcript) + await my_subtask(5) + + subtask_events = [ + event for event in transcript.events if isinstance(event, SubtaskEvent) + ] + assert subtask_events + for event in subtask_events: + assert event.completed is not None + assert event.working_time is not None + assert event.working_time >= 0 diff --git a/tests/properties/__init__.py b/tests/properties/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/properties/test_model_event_invariants.py b/tests/properties/test_model_event_invariants.py new file mode 100644 index 0000000000..de98e365c5 --- /dev/null +++ b/tests/properties/test_model_event_invariants.py @@ -0,0 +1,157 @@ +"""Top-level retry-event invariants.""" + +# pyright: reportImplicitRelativeImport=false + +# ruff: noqa: E402 + +from datetime import datetime, timedelta, timezone +from typing import Any, cast + +import anyio +import pytest + +hypothesis = pytest.importorskip("hypothesis") +from _helpers.event_assertions import assert_no_legacy_rewrite, model_events +from _helpers.retry_provider import ( + RetryableModelError, + install_retry_classifier, + make_mockllm_with_callable, +) +from _helpers.strategies import RetryScenario, retry_scenarios + +HealthCheck = cast(Any, hypothesis.HealthCheck) +given = cast(Any, hypothesis.given) +settings = cast(Any, hypothesis.settings) +st = cast(Any, hypothesis.strategies) + +from inspect_ai.event._model import ModelEvent +from inspect_ai.log._transcript import Transcript, init_transcript +from inspect_ai.model import GenerateConfig +from inspect_ai.model._chat_message import ChatMessageUser +from inspect_ai.model._generate_accounting import ModelGenerateAccounting +from inspect_ai.model._model_output import ModelOutput + + +@given(scenario=retry_scenarios()) +@settings( + max_examples=25, + deadline=5000, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture], +) +def test_property_attempts_contiguous(scenario: RetryScenario) -> None: + anyio.run(_check_property_attempts_contiguous, scenario) + + +async def _check_property_attempts_contiguous(scenario: RetryScenario) -> None: + terminal = scenario.outcomes[-1] + remaining = [scenario.outcomes.count("fail-retryable")] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + if terminal != "success": + raise ValueError("permanent") + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + try: + await model.generate( + "x", config=GenerateConfig(max_retries=scenario.max_retries) + ) + except Exception: + pass + + events = model_events(transcript) + if events: + assert len({event.call_id for event in events}) == 1 + assert [event.attempt for event in events] == list(range(1, len(events) + 1)) + + +@given(scenario=retry_scenarios()) +@settings(max_examples=25, deadline=5000) +def test_property_no_legacy_rewrite(scenario: RetryScenario) -> None: + anyio.run(_check_property_no_legacy_rewrite, scenario) + + +async def _check_property_no_legacy_rewrite(scenario: RetryScenario) -> None: + remaining = [scenario.outcomes.count("fail-retryable")] + + def custom_outputs(input, tools, tool_choice, config): + if remaining[0] > 0: + remaining[0] -= 1 + raise RetryableModelError("transient") + return ModelOutput.from_content("mockllm", "ok") + + transcript = Transcript() + init_transcript(transcript) + model = make_mockllm_with_callable(custom_outputs) + install_retry_classifier(model) + try: + await model.generate( + "x", config=GenerateConfig(max_retries=scenario.max_retries) + ) + except Exception: + pass + assert_no_legacy_rewrite(model_events(transcript)) + + +@given( + offset=st.floats(min_value=0.0, max_value=60.0, allow_nan=False), + working_time=st.floats(min_value=0.0, max_value=60.0, allow_nan=False), +) +def test_property_call_working_time_le_wall_duration( + offset: float, working_time: float +) -> None: + started = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + completed = started + timedelta(seconds=offset + working_time) + event = ModelEvent( + model="x", + input=[ChatMessageUser(content="x")], + tools=[], + tool_choice="auto", + config=GenerateConfig(), + output=ModelOutput.from_content("x", "y"), + call_id="abc", + attempt=1, + call_started_at=started, + call_completed_at=completed, + call_working_start=0.0, + call_working_time=working_time, + ) + assert event.call_completed_at is not None + assert event.call_started_at is not None + assert event.call_working_time is not None + wall = (event.call_completed_at - event.call_started_at).total_seconds() + assert event.call_working_time <= wall + 1e-6 + + +@given( + working_now=st.floats(min_value=-100.0, max_value=100.0, allow_nan=False), + working_start=st.floats(min_value=0.0, max_value=100.0, allow_nan=False), +) +def test_property_finalize_clamps_negative_working_time( + working_now: float, working_start: float +) -> None: + acc = ModelGenerateAccounting.new( + started_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + working_start=working_start, + ) + event = ModelEvent( + model="x", + input=[ChatMessageUser(content="x")], + tools=[], + tool_choice="auto", + config=GenerateConfig(), + output=ModelOutput.from_content("x", "y"), + ) + acc.finalize_terminal_event( + event=event, + completed_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + working_now=working_now, + ) + assert event.call_working_time is not None + assert event.call_working_time >= 0 diff --git a/uv.lock b/uv.lock index 902a61908a..1c6418d80a 100644 --- a/uv.lock +++ b/uv.lock @@ -1846,6 +1846,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/79/621a7dbb80c70974f73a597275351ebe03ce5bc65cb5f8f4acb5859252bc/huggingface_hub-1.16.1-py3-none-any.whl", hash = "sha256:64340de934b9ce37857ef85a82de72f5629e8a270f9119eabb12bf495eb53c22", size = 668176, upload-time = "2026-05-21T18:39:58.596Z" }, ] +[[package]] +name = "hypothesis" +version = "6.156.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/83/8dbe89bdb8c6f25a7a52e7898af6d82fe35dfef08e5c702f6e33231ce6c6/hypothesis-6.156.6.tar.gz", hash = "sha256:96de02faefa3ce079873541da96f42595583bb001e8e4219294ed7d4501cc4cc", size = 476304, upload-time = "2026-07-10T20:56:49.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/dc/0c2a851f06c91d5ac9ef0f3b9615efc1ed650411d2eee23b6334f491c85e/hypothesis-6.156.6-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:caf6a93d011c10972da111c38ceb34ced20feaa8581e2b350c0655b022e27875", size = 747998, upload-time = "2026-07-10T20:56:16.311Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f8/59203ca978ab51595d12d6bc7e7a63300d7373431ab42ca3f1742e45db68/hypothesis-6.156.6-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:07f2bc9df1aeba80e12029c1618e2ee54abc440068c305d7075ffd6b85251843", size = 743073, upload-time = "2026-07-10T20:55:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/68/d8/86a0023740434098d1b187a62bd5f99b198f098fb43e7fc58342283a8270/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7baca17f4803ad4aa151732326f3990baf54c3127df44aa872ac5bdf8a98a9a6", size = 1070169, upload-time = "2026-07-10T20:55:49.47Z" }, + { url = "https://files.pythonhosted.org/packages/9b/82/673453915fd0c67673f35a4876ba88f48c621335f293f3537d77b27d4286/hypothesis-6.156.6-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8083806645f84243aade727f4978185caaa0b7190af4318673999ee15fdbf424", size = 1121760, upload-time = "2026-07-10T20:55:53.502Z" }, + { url = "https://files.pythonhosted.org/packages/8a/c3/3a5557f52912f2fecc6ed59642dcf80dd8e89d0d9664502b68e23d66bf3d/hypothesis-6.156.6-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a922eedcd8618f9c2e17b79fa7b3f3f0b2df34e201958611cc3f0f46cca33c10", size = 1111440, upload-time = "2026-07-10T20:55:43.054Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/ae636d4ca7f996a1ccb4b3d5997d949f1718fba52b01559b3ab53b237b3f/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5291bd33c4704d274d7c214d5c200e77f372a06644f5cbbe96dcbe53cb2fbf10", size = 1244944, upload-time = "2026-07-10T20:55:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/1e/79/c425d22d734be0268ca60d120c6296299e4220a1783cb1a4cc76232807bb/hypothesis-6.156.6-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:55f3ec50161b4a95bae63bff2b5166e45935b493013d3be30ede279bf6192318", size = 1288808, upload-time = "2026-07-10T20:56:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/cc9f479d22cbdd36ddfc55a978378eddadd183b09339ebdb81be33bb18e7/hypothesis-6.156.6-cp310-abi3-win32.whl", hash = "sha256:e96570ca5cdd9a5f2ff9e80a6fb2fd5420ebf33b833d7de5b09b6ebb26a3eb6c", size = 634868, upload-time = "2026-07-10T20:55:37.959Z" }, + { url = "https://files.pythonhosted.org/packages/d6/89/2008d287289841a936456cb13443ca89d88da6e4527d611d482e9544164d/hypothesis-6.156.6-cp310-abi3-win_amd64.whl", hash = "sha256:32710718c22fe8c5571464e898bb87d282837b02617d6ad68130abf7cb4843cb", size = 640382, upload-time = "2026-07-10T20:55:30.634Z" }, + { url = "https://files.pythonhosted.org/packages/0b/dc/b502504a972af7368b62e712268d310ffbc81d0ebfb31d5a9c60332a5063/hypothesis-6.156.6-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:afec0631a7a557acb50f665108b5d1d1123c21bc702d156a740db1cc33be4a7d", size = 749319, upload-time = "2026-07-10T20:55:35.708Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/a4867bc2b8b81d9b1648992fb7e4a732b3db480ff2d02df2c7b59189c812/hypothesis-6.156.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583a658162ee7e1a82155e3f146932a3a2269030294f3c83f5cf52fcaa0562c3", size = 743969, upload-time = "2026-07-10T20:55:31.983Z" }, + { url = "https://files.pythonhosted.org/packages/2e/22/6ece4337e01c634594bb177009c049aa3133c151d9e397edccb6c3938567/hypothesis-6.156.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9600277defbfa769d8a6af264cbdff16294682c210c2d058ef39348fec16c0c", size = 1070874, upload-time = "2026-07-10T20:55:44.277Z" }, + { url = "https://files.pythonhosted.org/packages/05/00/5820a3b1fe264b23770f77dbb3ac0f6fdadd21b640624791a05950f934da/hypothesis-6.156.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7c3f067166bfc28b67f0042fc5fdcc87b3b58664da0c2f563fe544448b7ab3b", size = 1122191, upload-time = "2026-07-10T20:56:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/0861ed15c96302577229334655e038e8c46e4b5b2a8cae971409a7e176e5/hypothesis-6.156.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f57842c1c5314839bdf4be5cff108589ff435cd7192c035dc48e6f14032915a5", size = 1246060, upload-time = "2026-07-10T20:55:41.834Z" }, + { url = "https://files.pythonhosted.org/packages/64/5c/63b60c6566eafac0b150f6acae5d1cbe0ed64dc294c863888eb87088a542/hypothesis-6.156.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c4a787c3af0035846034461792f2a62f1c43af850feb970a5b53a629d64b52fb", size = 1289510, upload-time = "2026-07-10T20:55:33.133Z" }, + { url = "https://files.pythonhosted.org/packages/96/4e/bfe57f182f51784549210903241057ae94784858117b965613089f5f89ad/hypothesis-6.156.6-cp310-cp310-win_amd64.whl", hash = "sha256:02accb187617ebaebb120da931f799a3cb0df7c38706f97f9d022441d4faf533", size = 640384, upload-time = "2026-07-10T20:56:26.939Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/e4a0796190d8089e85f06731e21fdddd7e8edd3a4e562101527a048e21c4/hypothesis-6.156.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5baec7943a14d106e982121dd4f74cfc5ef45e37c17f94fe49338d3d1377f38c", size = 748988, upload-time = "2026-07-10T20:56:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/4a789b286cd2cced31992e1f683036b51dd6909b934ea007ffb43aa3a32f/hypothesis-6.156.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5f519905ddeb10e23b8ba2c254541a5b1a8f146fe0551be94d972f4a77226f4", size = 743754, upload-time = "2026-07-10T20:56:09.113Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f7/3dd36c1c03d24ae3ffc3c5b0eca8cc4ae90c07abc320f76509eceb37019a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c108655960b58ded3ca71b2dc5c69fb2ba7e9c723aeb6106facec3892d09087", size = 1070732, upload-time = "2026-07-10T20:56:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/57/51/befc4b816b471078034a875eb1ef69e0411ab84bcce582b4be173258785a/hypothesis-6.156.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c8d37bebb6924729bc0bbf5852689df568842948abe4d93dd0ad3377adf76fa", size = 1121988, upload-time = "2026-07-10T20:56:07.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/b7/a796f5e3e4b7cb911ff346008d49720296d1f4073490b8bc1cce6b3fbb07/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74137bef6d502305c3648b2ed1a9bb4bc05fb1025e96b30a2c092204c40fe097", size = 1245596, upload-time = "2026-07-10T20:55:50.662Z" }, + { url = "https://files.pythonhosted.org/packages/37/65/849c4cba44a6f6cc888fd931124429b24180234ccc4883abab8cad5fcfcb/hypothesis-6.156.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e0afdf79cceed20fcf0a9fb80d4064a9b2b53d4d4eecbac0e21208a13f5a31b", size = 1289172, upload-time = "2026-07-10T20:55:58.915Z" }, + { url = "https://files.pythonhosted.org/packages/13/03/7106a110df29eb631d66776e8aa8128f82f04a9dd2b6b22b612e6025e3a2/hypothesis-6.156.6-cp311-cp311-win_amd64.whl", hash = "sha256:84dc89caaf741a02f904ca7bd02b1af99650c75552868162290208aeecb70858", size = 640222, upload-time = "2026-07-10T20:56:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/8c/45/9f009005b9c796f4a40424484ac7e70847bc088456fd940a937f96bb4b6d/hypothesis-6.156.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a2a728b514fceb81e3f0464508911d5220fd74dadc3270f859427a686b60c4cf", size = 748844, upload-time = "2026-07-10T20:56:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/4d852bb8a9c73a68b18eca9b5b085285282122166e158f4d2a477639bfee/hypothesis-6.156.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7489b9a8f9df8227edd6c7cd8b9ccfab2483bab24da6a474c175973ca2294f58", size = 741936, upload-time = "2026-07-10T20:55:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/b9968070ae042f9bf3149bb6ba6399d5f28f452e0fb7f638cafc69ff0b9a/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42760873d6db1069d6edbaa355a61b9078a9950259efcfc72fc695741d7db7cd", size = 1069749, upload-time = "2026-07-10T20:56:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/a9/753806f5292b40aeab1d269e408e3a7e85be3c0d88828fb78ab4a34d6626/hypothesis-6.156.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b4e66aaa7385538a5d617174d47c198ee807f06de99e282a67c6cb724c69340d", size = 1120983, upload-time = "2026-07-10T20:56:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/85/88/8386d064d680be27e936eba94f1448bc93ef6fa05473ee5034139f1c4284/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:08796b674c0b31a5dd4119b2173823390055921588d13eb77324e861b00fd7f8", size = 1243911, upload-time = "2026-07-10T20:55:54.799Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8c/7524c1e5279e7728eb47c99f2357cbc5f08ae92e9bce49bf50118b53f9c9/hypothesis-6.156.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4ca8cc26ea2d31d22cf7710e92951cfaa921f0f8aa1b6db33a5176335f583a4f", size = 1287806, upload-time = "2026-07-10T20:56:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b3/c347ad913e1c5f2988956fe17826c0400b4ce470b973e6c248e97b6a0acf/hypothesis-6.156.6-cp312-cp312-win_amd64.whl", hash = "sha256:c3363d3fb8015594636689572510bb6090602d8e8e838a5693c2d52d3b5b09d8", size = 637679, upload-time = "2026-07-10T20:55:39.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/5d/9583fe153573523dac27226c89e041a86ad4aeeae08c868160cbb93d39d2/hypothesis-6.156.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:59a8def90d9a5a9b67e1ac529e903a2363ceb6cf873c209da6b4284c5daab671", size = 749264, upload-time = "2026-07-10T20:56:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/86/35/e4113d06769b544f0fb77ffea9195b598b4c56a298905c21fd47c4eed388/hypothesis-6.156.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c574c3224563d730848bc5d1ef1683c4f83993400c0167899fe328f4bfcd4725", size = 742095, upload-time = "2026-07-10T20:56:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5c/a47666ede10384e8978722cade7ab96a42df71d2ab577317092d0fed7c8a/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01bb8270c46b3ef53b0c2d23ff613ea506d609d06f936d823ea57c58b66b05f7", size = 1069917, upload-time = "2026-07-10T20:56:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/79/93/75f6057dadd9dc0134f37c08d5d14d04d3cd7374debbcb0cc4569c6712f1/hypothesis-6.156.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4ea6559c13606e13b645927f2e0906e52b5ac5d99b40d3abaaeb2e8c7ceeb75", size = 1121204, upload-time = "2026-07-10T20:55:52.008Z" }, + { url = "https://files.pythonhosted.org/packages/62/87/308efef08bc60d1e673d035e8ca8e9663f4b6b3ba519c3cdebf6583c2b76/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2d47054d0230f0dd9b6868fc030126c7a6c25527144272ff376cc4e9c39f7540", size = 1244168, upload-time = "2026-07-10T20:55:40.288Z" }, + { url = "https://files.pythonhosted.org/packages/3b/66/de8fff5bd9a40a4056dafbe7f904887ef12632282bbbac90f1977c30dd3b/hypothesis-6.156.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:050c8c0815f88d47dd0875a92698d20d61639b7b721ee043a6d687c7f14ff7d8", size = 1288127, upload-time = "2026-07-10T20:56:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/794fb26e1fd3ff004978f8f18b7aa7e1c2270ba72e1f977b987a812064f8/hypothesis-6.156.6-cp313-cp313-win_amd64.whl", hash = "sha256:f0d73edab7b8a0051b3634f2d04d62b7e7282f8f274963b11188ee4957d672ef", size = 637954, upload-time = "2026-07-10T20:56:33.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/5b4b27984cb43c60e95f570b069660335dad34cb67f7d226017c5d35d31e/hypothesis-6.156.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:34a70a7b8226e34d658072d8fb81d03f97f0a75ceb536329a321b94ce2232fd6", size = 749312, upload-time = "2026-07-10T20:55:46.902Z" }, + { url = "https://files.pythonhosted.org/packages/31/11/709cceffc28666c9d4cb75ffc6df5ce30db8c7dd5cc2c8b38a2fd837427f/hypothesis-6.156.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f1969646beead7d8cf6a2537d2765af89d73056e2cb218e7fae92b83802250a3", size = 742332, upload-time = "2026-07-10T20:56:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/cfc79b13d8dd3cd6de6b9df921c557efe8528a9c90a3a7cd93b37188d57e/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cbc2ec7b7d905e6b6ec1635f6340bfa52aaab718101c59f052bc012a6b486cd8", size = 1070109, upload-time = "2026-07-10T20:55:48.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/1da4def1f006b5ad01187ff96379e24c37439d659ec10c3e944c03436c0f/hypothesis-6.156.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9367ae25dfa6dc1af37904785e43c4f8fe1c4118cafdc2f06514154fbdd90992", size = 1121528, upload-time = "2026-07-10T20:56:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/68/47/744e4f5e3d635dea20dbedf3fa486e2a6fa5210e0a52a0d5c4da56babd84/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:455f09107ec07c78f2a83cb8fc19e23879c9d51cdc831de6f9cb6ec4059cb9af", size = 1244690, upload-time = "2026-07-10T20:56:31.854Z" }, + { url = "https://files.pythonhosted.org/packages/25/8a/42252fcd5e521d140dac532f29c2a13ca8f22908cb545ffdd64b5e225680/hypothesis-6.156.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c76634c45a3ceee4c4fdfed39aebd08b8b822ec8b0c556877ef82846fd777730", size = 1288519, upload-time = "2026-07-10T20:56:03.429Z" }, + { url = "https://files.pythonhosted.org/packages/44/e7/176df9e47cf583d2b8d234b78c0aac3a47075ad5d147e60b2c21a1338bb1/hypothesis-6.156.6-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:eb7e9f8343bc6b948937e6ec12e6879ed25a17b53ceccbd2b84adadd3d511698", size = 586452, upload-time = "2026-07-10T20:56:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/2c8a0411bbe76429f3ae738ef9a00107201bf6146d9534350014ce369d98/hypothesis-6.156.6-cp314-cp314-win_amd64.whl", hash = "sha256:f9631cd604ae6032c3edf99160dc1b9e33873f2e52762246b24f07fb758652ae", size = 637774, upload-time = "2026-07-10T20:56:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/8115005e9aa72c8d63d90e9db5e0b8425fd8950fbc5d6e332805d4d32c9e/hypothesis-6.156.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1f81163d36d3763b09ffaef7c3a71e88174ca3e6816201fca9d1d159f448fdb5", size = 747428, upload-time = "2026-07-10T20:56:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c2/66bfe9337f4a4b1f7754ee6d01d950280152a81d0d797e6c1d9eb0909750/hypothesis-6.156.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:556b905767e36147918634a64356aa05d8c956576f00aee01eb351678f193908", size = 740889, upload-time = "2026-07-10T20:55:57.656Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/69f45af2d4f0950b7d1af3cdbdd800b88a6c2370331481eda79d6171fbe3/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3f2604b28d16d696aaaf4954d20f907b27e54034df98e64746a20c74c319f03", size = 1069270, upload-time = "2026-07-10T20:56:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/f8/43/6b2549885da08f5e50ba34fb8e0d0a60b2f190ffd516fac220f8db5b5869/hypothesis-6.156.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe012ad66dbe7b8e8ddef6f6992ab1b36719ea64430c2bf1ff7135521052a15", size = 1120409, upload-time = "2026-07-10T20:55:34.551Z" }, + { url = "https://files.pythonhosted.org/packages/70/97/745c778c3eb29befa2367b1ded8437eecfbbe6932359d0f831275bc32170/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5bfa3c7b758f7278081c6bfec5f89b43c4eb075c0c9eb095323f7a9eb019b513", size = 1243111, upload-time = "2026-07-10T20:56:17.83Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/c5ec6a442dc9b822f47064bda4b6d3e739dccdd1c5bf44c9a57fb6136830/hypothesis-6.156.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0db1f4573800c618773622f03cb6533bb3377430ef938c9476ba10c39d22591", size = 1287262, upload-time = "2026-07-10T20:56:23.749Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/c134d61710e14b68b010215dcf6bd57d2ec05cd169dff8bfab8fefc2d410/hypothesis-6.156.6-cp314-cp314t-win_amd64.whl", hash = "sha256:38cd0c4a7b9f809f1e23a4d15adfa9c5d99869b9afc327350a5e563350b78e48", size = 637862, upload-time = "2026-07-10T20:56:13.347Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/b94f3a31665527b6181616b72990fcf8d6d5fa82b4187aab104ab5f548f0/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8893b4da90e06828846c1b100c3414a7729d047a020d854c0899ae9339df0e70", size = 749575, upload-time = "2026-07-10T20:55:29.371Z" }, + { url = "https://files.pythonhosted.org/packages/21/74/dcf695f79f526543ae5d0f8c1325508e9fe990a996c0e0853129a9a5d81d/hypothesis-6.156.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7fc0b7df9b28d028e4cc295b2ac8fbbbc22e090a23382c92fff5e37696be74f", size = 744351, upload-time = "2026-07-10T20:56:36.46Z" }, + { url = "https://files.pythonhosted.org/packages/11/d3/5bff4c55c6995a6c43f66ec8e5866b56e34f03837fd0be0e4922f3bab168/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38ed3178526382d392d04ad699ad7a2e53845e521a09d40f1cbbc1e1ff63ba48", size = 1070916, upload-time = "2026-07-10T20:56:19.256Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/5ed42117a69221ea118caaff933d8212039a0ac0bc15afa915635f13984c/hypothesis-6.156.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ad94e28aabf4db0d479297d43b8a2a01e7caaa9bdfccfdac7a4a3717e05b993", size = 1122625, upload-time = "2026-07-10T20:56:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d3/02499badc6e3f3e980941021edf5fd780c895d8d08c9015e78516340ed83/hypothesis-6.156.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:983a5cfd955994bffc7eb02976241f7a1f3c2d94dbc3389430c45858fa5c1ae0", size = 640823, upload-time = "2026-07-10T20:55:45.461Z" }, +] + [[package]] name = "id" version = "1.6.1" @@ -2047,6 +2116,7 @@ dev = [ { name = "griffe" }, { name = "groq" }, { name = "huggingface-hub" }, + { name = "hypothesis" }, { name = "inspect-scout" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -2055,6 +2125,7 @@ dev = [ { name = "mcp" }, { name = "mistralai" }, { name = "moto", extra = ["server"] }, + { name = "mutmut" }, { name = "mypy" }, { name = "nbformat" }, { name = "openai" }, @@ -2143,6 +2214,7 @@ requires-dist = [ { name = "groq", marker = "extra == 'dev'", specifier = ">=0.28.0" }, { name = "httpx" }, { name = "huggingface-hub", marker = "extra == 'dev'", specifier = ">=1.6.0" }, + { name = "hypothesis", marker = "extra == 'dev'" }, { name = "ijson", specifier = ">=3.2.0" }, { name = "inspect-scout", marker = "extra == 'dev'", specifier = ">=0.4.37" }, { name = "ipython", marker = "extra == 'dev'" }, @@ -2159,6 +2231,7 @@ requires-dist = [ { name = "mistralai", marker = "extra == 'dev'", specifier = ">=2.4.5" }, { name = "mmh3", specifier = ">3.1.0" }, { name = "moto", extras = ["server"], marker = "extra == 'dev'", specifier = ">=5.1.5" }, + { name = "mutmut", marker = "extra == 'dev'" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.17.0" }, { name = "nbclient", marker = "extra == 'doc'" }, { name = "nbformat", marker = "extra == 'dev'" }, @@ -3788,6 +3861,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "mutmut" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "coverage" }, + { name = "libcst" }, + { name = "pytest" }, + { name = "setproctitle" }, + { name = "textual" }, + { name = "toml", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/b0/ebcae42b90b07756b7aa10c4176835f436332e6c1cb28bc35bae83462382/mutmut-3.6.0.tar.gz", hash = "sha256:bcbd3e4d0d2d4edf3dfb42955417279a8866a3dbbcb87d619f2f3fd0ac7fafda", size = 51538, upload-time = "2026-06-06T07:44:51.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/5a/a0caa3f9db407b5d12c311bd4c87aa67fdd6e3f329377149e303108a1c51/mutmut-3.6.0-py3-none-any.whl", hash = "sha256:a9f5b8dcf6cbf9496769d7cf8bdbba37a0ec709ad98f88d103238b62f10bdf37", size = 47770, upload-time = "2026-06-06T07:44:50.038Z" }, +] + [[package]] name = "mypy" version = "2.1.0" @@ -6259,6 +6350,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, ] +[[package]] +name = "setproctitle" +version = "1.3.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/48/49393a96a2eef1ab418b17475fb92b8fcfad83d099e678751b05472e69de/setproctitle-1.3.7.tar.gz", hash = "sha256:bc2bc917691c1537d5b9bca1468437176809c7e11e5694ca79a9ca12345dcb9e", size = 27002, upload-time = "2025-09-05T12:51:25.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/48/fb401ec8c4953d519d05c87feca816ad668b8258448ff60579ac7a1c1386/setproctitle-1.3.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cf555b6299f10a6eb44e4f96d2f5a3884c70ce25dc5c8796aaa2f7b40e72cb1b", size = 18079, upload-time = "2025-09-05T12:49:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a3/c2b0333c2716fb3b4c9a973dd113366ac51b4f8d56b500f4f8f704b4817a/setproctitle-1.3.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:690b4776f9c15aaf1023bb07d7c5b797681a17af98a4a69e76a1d504e41108b7", size = 13099, upload-time = "2025-09-05T12:49:09.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f8/17bda581c517678260e6541b600eeb67745f53596dc077174141ba2f6702/setproctitle-1.3.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:00afa6fc507967d8c9d592a887cdc6c1f5742ceac6a4354d111ca0214847732c", size = 31793, upload-time = "2025-09-05T12:49:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/27/d1/76a33ae80d4e788ecab9eb9b53db03e81cfc95367ec7e3fbf4989962fedd/setproctitle-1.3.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e02667f6b9fc1238ba753c0f4b0a37ae184ce8f3bbbc38e115d99646b3f4cd3", size = 32779, upload-time = "2025-09-05T12:49:12.157Z" }, + { url = "https://files.pythonhosted.org/packages/59/27/1a07c38121967061564f5e0884414a5ab11a783260450172d4fc68c15621/setproctitle-1.3.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83fcd271567d133eb9532d3b067c8a75be175b2b3b271e2812921a05303a693f", size = 34578, upload-time = "2025-09-05T12:49:13.393Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/725e6353935962d8bb12cbf7e7abba1d0d738c7f6935f90239d8e1ccf913/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13fe37951dda1a45c35d77d06e3da5d90e4f875c4918a7312b3b4556cfa7ff64", size = 32030, upload-time = "2025-09-05T12:49:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/e4677ae8e1cb0d549ab558b12db10c175a889be0974c589c428fece5433e/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a05509cfb2059e5d2ddff701d38e474169e9ce2a298cf1b6fd5f3a213a553fe5", size = 33363, upload-time = "2025-09-05T12:49:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/69ce66e4373a48fdbb37489f3ded476bb393e27f514968c3a69a67343ae0/setproctitle-1.3.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6da835e76ae18574859224a75db6e15c4c2aaa66d300a57efeaa4c97ca4c7381", size = 31508, upload-time = "2025-09-05T12:49:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5a/42c1ed0e9665d068146a68326529b5686a1881c8b9197c2664db4baf6aeb/setproctitle-1.3.7-cp310-cp310-win32.whl", hash = "sha256:9e803d1b1e20240a93bac0bc1025363f7f80cb7eab67dfe21efc0686cc59ad7c", size = 12558, upload-time = "2025-09-05T12:49:19.742Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/dd206cc19a25561921456f6cb12b405635319299b6f366e0bebe872abc18/setproctitle-1.3.7-cp310-cp310-win_amd64.whl", hash = "sha256:a97200acc6b64ec4cada52c2ecaf1fba1ef9429ce9c542f8a7db5bcaa9dcbd95", size = 13245, upload-time = "2025-09-05T12:49:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/cd/1b7ba5cad635510720ce19d7122154df96a2387d2a74217be552887c93e5/setproctitle-1.3.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a600eeb4145fb0ee6c287cb82a2884bd4ec5bbb076921e287039dcc7b7cc6dd0", size = 18085, upload-time = "2025-09-05T12:49:22.183Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/b2da0a620490aae355f9d72072ac13e901a9fec809a6a24fc6493a8f3c35/setproctitle-1.3.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:97a090fed480471bb175689859532709e28c085087e344bca45cf318034f70c4", size = 13097, upload-time = "2025-09-05T12:49:23.322Z" }, + { url = "https://files.pythonhosted.org/packages/18/2e/bd03ff02432a181c1787f6fc2a678f53b7dacdd5ded69c318fe1619556e8/setproctitle-1.3.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1607b963e7b53e24ec8a2cb4e0ab3ae591d7c6bf0a160feef0551da63452b37f", size = 32191, upload-time = "2025-09-05T12:49:24.567Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/1e62fc0937a8549f2220445ed2175daacee9b6764c7963b16148119b016d/setproctitle-1.3.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a20fb1a3974e2dab857870cf874b325b8705605cb7e7e8bcbb915bca896f52a9", size = 33203, upload-time = "2025-09-05T12:49:25.871Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/65edc65db3fa3df400cf13b05e9d41a3c77517b4839ce873aa6b4043184f/setproctitle-1.3.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8d961bba676e07d77665204f36cffaa260f526e7b32d07ab3df6a2c1dfb44ba", size = 34963, upload-time = "2025-09-05T12:49:27.044Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/89157e3de997973e306e44152522385f428e16f92f3cf113461489e1e2ee/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:db0fd964fbd3a9f8999b502f65bd2e20883fdb5b1fae3a424e66db9a793ed307", size = 32398, upload-time = "2025-09-05T12:49:28.909Z" }, + { url = "https://files.pythonhosted.org/packages/4a/18/77a765a339ddf046844cb4513353d8e9dcd8183da9cdba6e078713e6b0b2/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:db116850fcf7cca19492030f8d3b4b6e231278e8fe097a043957d22ce1bdf3ee", size = 33657, upload-time = "2025-09-05T12:49:30.323Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/f0b6205c64d74d2a24a58644a38ec77bdbaa6afc13747e75973bf8904932/setproctitle-1.3.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:316664d8b24a5c91ee244460bdaf7a74a707adaa9e14fbe0dc0a53168bb9aba1", size = 31836, upload-time = "2025-09-05T12:49:32.309Z" }, + { url = "https://files.pythonhosted.org/packages/ba/51/e1277f9ba302f1a250bbd3eedbbee747a244b3cc682eb58fb9733968f6d8/setproctitle-1.3.7-cp311-cp311-win32.whl", hash = "sha256:b74774ca471c86c09b9d5037c8451fff06bb82cd320d26ae5a01c758088c0d5d", size = 12556, upload-time = "2025-09-05T12:49:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/822a23f17e9003dfdee92cd72758441ca2a3680388da813a371b716fb07f/setproctitle-1.3.7-cp311-cp311-win_amd64.whl", hash = "sha256:acb9097213a8dd3410ed9f0dc147840e45ca9797785272928d4be3f0e69e3be4", size = 13243, upload-time = "2025-09-05T12:49:34.553Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f0/2dc88e842077719d7384d86cc47403e5102810492b33680e7dadcee64cd8/setproctitle-1.3.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2dc99aec591ab6126e636b11035a70991bc1ab7a261da428491a40b84376654e", size = 18049, upload-time = "2025-09-05T12:49:36.241Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b4/50940504466689cda65680c9e9a1e518e5750c10490639fa687489ac7013/setproctitle-1.3.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cdd8aa571b7aa39840fdbea620e308a19691ff595c3a10231e9ee830339dd798", size = 13079, upload-time = "2025-09-05T12:49:38.088Z" }, + { url = "https://files.pythonhosted.org/packages/d0/99/71630546b9395b095f4082be41165d1078204d1696c2d9baade3de3202d0/setproctitle-1.3.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2906b6c7959cdb75f46159bf0acd8cc9906cf1361c9e1ded0d065fe8f9039629", size = 32932, upload-time = "2025-09-05T12:49:39.271Z" }, + { url = "https://files.pythonhosted.org/packages/50/22/cee06af4ffcfb0e8aba047bd44f5262e644199ae7527ae2c1f672b86495c/setproctitle-1.3.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6915964a6dda07920a1159321dcd6d94fc7fc526f815ca08a8063aeca3c204f1", size = 33736, upload-time = "2025-09-05T12:49:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/5c/00/a5949a8bb06ef5e7df214fc393bb2fb6aedf0479b17214e57750dfdd0f24/setproctitle-1.3.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff72899861c765bd4021d1ff1c68d60edc129711a2fdba77f9cb69ef726a8b6", size = 35605, upload-time = "2025-09-05T12:49:42.362Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3a/50caca532a9343828e3bf5778c7a84d6c737a249b1796d50dd680290594d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7cb05bd446687ff816a3aaaf831047fc4c364feff7ada94a66024f1367b448c", size = 33143, upload-time = "2025-09-05T12:49:43.515Z" }, + { url = "https://files.pythonhosted.org/packages/ca/14/b843a251296ce55e2e17c017d6b9f11ce0d3d070e9265de4ecad948b913d/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3a57b9a00de8cae7e2a1f7b9f0c2ac7b69372159e16a7708aa2f38f9e5cc987a", size = 34434, upload-time = "2025-09-05T12:49:45.31Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b7/06145c238c0a6d2c4bc881f8be230bb9f36d2bf51aff7bddcb796d5eed67/setproctitle-1.3.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d8828b356114f6b308b04afe398ed93803d7fca4a955dd3abe84430e28d33739", size = 32795, upload-time = "2025-09-05T12:49:46.419Z" }, + { url = "https://files.pythonhosted.org/packages/ef/dc/ef76a81fac9bf27b84ed23df19c1f67391a753eed6e3c2254ebcb5133f56/setproctitle-1.3.7-cp312-cp312-win32.whl", hash = "sha256:b0304f905efc845829ac2bc791ddebb976db2885f6171f4a3de678d7ee3f7c9f", size = 12552, upload-time = "2025-09-05T12:49:47.635Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5b/a9fe517912cd6e28cf43a212b80cb679ff179a91b623138a99796d7d18a0/setproctitle-1.3.7-cp312-cp312-win_amd64.whl", hash = "sha256:9888ceb4faea3116cf02a920ff00bfbc8cc899743e4b4ac914b03625bdc3c300", size = 13247, upload-time = "2025-09-05T12:49:49.16Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2f/fcedcade3b307a391b6e17c774c6261a7166aed641aee00ed2aad96c63ce/setproctitle-1.3.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3736b2a423146b5e62230502e47e08e68282ff3b69bcfe08a322bee73407922", size = 18047, upload-time = "2025-09-05T12:49:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/23/ae/afc141ca9631350d0a80b8f287aac79a76f26b6af28fd8bf92dae70dc2c5/setproctitle-1.3.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3384e682b158d569e85a51cfbde2afd1ab57ecf93ea6651fe198d0ba451196ee", size = 13073, upload-time = "2025-09-05T12:49:51.46Z" }, + { url = "https://files.pythonhosted.org/packages/87/ed/0a4f00315bc02510395b95eec3d4aa77c07192ee79f0baae77ea7b9603d8/setproctitle-1.3.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0564a936ea687cd24dffcea35903e2a20962aa6ac20e61dd3a207652401492dd", size = 33284, upload-time = "2025-09-05T12:49:52.741Z" }, + { url = "https://files.pythonhosted.org/packages/fc/e4/adf3c4c0a2173cb7920dc9df710bcc67e9bcdbf377e243b7a962dc31a51a/setproctitle-1.3.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5d1cb3f81531f0eb40e13246b679a1bdb58762b170303463cb06ecc296f26d0", size = 34104, upload-time = "2025-09-05T12:49:54.416Z" }, + { url = "https://files.pythonhosted.org/packages/52/4f/6daf66394152756664257180439d37047aa9a1cfaa5e4f5ed35e93d1dc06/setproctitle-1.3.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a7d159e7345f343b44330cbba9194169b8590cb13dae940da47aa36a72aa9929", size = 35982, upload-time = "2025-09-05T12:49:56.295Z" }, + { url = "https://files.pythonhosted.org/packages/1b/62/f2c0595403cf915db031f346b0e3b2c0096050e90e0be658a64f44f4278a/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0b5074649797fd07c72ca1f6bff0406f4a42e1194faac03ecaab765ce605866f", size = 33150, upload-time = "2025-09-05T12:49:58.025Z" }, + { url = "https://files.pythonhosted.org/packages/a0/29/10dd41cde849fb2f9b626c846b7ea30c99c81a18a5037a45cc4ba33c19a7/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61e96febced3f61b766115381d97a21a6265a0f29188a791f6df7ed777aef698", size = 34463, upload-time = "2025-09-05T12:49:59.424Z" }, + { url = "https://files.pythonhosted.org/packages/71/3c/cedd8eccfaf15fb73a2c20525b68c9477518917c9437737fa0fda91e378f/setproctitle-1.3.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:047138279f9463f06b858e579cc79580fbf7a04554d24e6bddf8fe5dddbe3d4c", size = 32848, upload-time = "2025-09-05T12:50:01.107Z" }, + { url = "https://files.pythonhosted.org/packages/d1/3e/0a0e27d1c9926fecccfd1f91796c244416c70bf6bca448d988638faea81d/setproctitle-1.3.7-cp313-cp313-win32.whl", hash = "sha256:7f47accafac7fe6535ba8ba9efd59df9d84a6214565108d0ebb1199119c9cbbd", size = 12544, upload-time = "2025-09-05T12:50:15.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/1b/6bf4cb7acbbd5c846ede1c3f4d6b4ee52744d402e43546826da065ff2ab7/setproctitle-1.3.7-cp313-cp313-win_amd64.whl", hash = "sha256:fe5ca35aeec6dc50cabab9bf2d12fbc9067eede7ff4fe92b8f5b99d92e21263f", size = 13235, upload-time = "2025-09-05T12:50:16.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/d588d3497d4714750e3eaf269e9e8985449203d82b16b933c39bd3fc52a1/setproctitle-1.3.7-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:10e92915c4b3086b1586933a36faf4f92f903c5554f3c34102d18c7d3f5378e9", size = 18058, upload-time = "2025-09-05T12:50:02.501Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/7637f7682322a7244e07c373881c7e982567e2cb1dd2f31bd31481e45500/setproctitle-1.3.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:de879e9c2eab637f34b1a14c4da1e030c12658cdc69ee1b3e5be81b380163ce5", size = 13072, upload-time = "2025-09-05T12:50:03.601Z" }, + { url = "https://files.pythonhosted.org/packages/52/09/f366eca0973cfbac1470068d1313fa3fe3de4a594683385204ec7f1c4101/setproctitle-1.3.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c18246d88e227a5b16248687514f95642505000442165f4b7db354d39d0e4c29", size = 34490, upload-time = "2025-09-05T12:50:04.948Z" }, + { url = "https://files.pythonhosted.org/packages/71/36/611fc2ed149fdea17c3677e1d0df30d8186eef9562acc248682b91312706/setproctitle-1.3.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7081f193dab22df2c36f9fc6d113f3793f83c27891af8fe30c64d89d9a37e152", size = 35267, upload-time = "2025-09-05T12:50:06.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/64e77d0671446bd5a5554387b69e1efd915274686844bea733714c828813/setproctitle-1.3.7-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cc9b901ce129350637426a89cfd650066a4adc6899e47822e2478a74023ff7c", size = 37376, upload-time = "2025-09-05T12:50:07.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/bc/ad9c664fe524fb4a4b2d3663661a5c63453ce851736171e454fa2cdec35c/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:80e177eff2d1ec172188d0d7fd9694f8e43d3aab76a6f5f929bee7bf7894e98b", size = 33963, upload-time = "2025-09-05T12:50:09.056Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a36de7caf2d90c4c28678da1466b47495cbbad43badb4e982d8db8167ed4/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:23e520776c445478a67ee71b2a3c1ffdafbe1f9f677239e03d7e2cc635954e18", size = 35550, upload-time = "2025-09-05T12:50:10.791Z" }, + { url = "https://files.pythonhosted.org/packages/dd/68/17e8aea0ed5ebc17fbf03ed2562bfab277c280e3625850c38d92a7b5fcd9/setproctitle-1.3.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5fa1953126a3b9bd47049d58c51b9dac72e78ed120459bd3aceb1bacee72357c", size = 33727, upload-time = "2025-09-05T12:50:12.032Z" }, + { url = "https://files.pythonhosted.org/packages/b2/33/90a3bf43fe3a2242b4618aa799c672270250b5780667898f30663fd94993/setproctitle-1.3.7-cp313-cp313t-win32.whl", hash = "sha256:4a5e212bf438a4dbeece763f4962ad472c6008ff6702e230b4f16a037e2f6f29", size = 12549, upload-time = "2025-09-05T12:50:13.074Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0e/50d1f07f3032e1f23d814ad6462bc0a138f369967c72494286b8a5228e40/setproctitle-1.3.7-cp313-cp313t-win_amd64.whl", hash = "sha256:cf2727b733e90b4f874bac53e3092aa0413fe1ea6d4f153f01207e6ce65034d9", size = 13243, upload-time = "2025-09-05T12:50:14.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/43ac3a98414f91d1b86a276bc2f799ad0b4b010e08497a95750d5bc42803/setproctitle-1.3.7-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:80c36c6a87ff72eabf621d0c79b66f3bdd0ecc79e873c1e9f0651ee8bf215c63", size = 18052, upload-time = "2025-09-05T12:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2c/dc258600a25e1a1f04948073826bebc55e18dbd99dc65a576277a82146fa/setproctitle-1.3.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b53602371a52b91c80aaf578b5ada29d311d12b8a69c0c17fbc35b76a1fd4f2e", size = 13071, upload-time = "2025-09-05T12:50:19.061Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/8e3bb082992f19823d831f3d62a89409deb6092e72fc6940962983ffc94f/setproctitle-1.3.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fcb966a6c57cf07cc9448321a08f3be6b11b7635be502669bc1d8745115d7e7f", size = 33180, upload-time = "2025-09-05T12:50:20.395Z" }, + { url = "https://files.pythonhosted.org/packages/f1/af/ae692a20276d1159dd0cf77b0bcf92cbb954b965655eb4a69672099bb214/setproctitle-1.3.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46178672599b940368d769474fe13ecef1b587d58bb438ea72b9987f74c56ea5", size = 34043, upload-time = "2025-09-05T12:50:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/34/b2/6a092076324dd4dac1a6d38482bedebbff5cf34ef29f58585ec76e47bc9d/setproctitle-1.3.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f9e9e3ff135cbcc3edd2f4cf29b139f4aca040d931573102742db70ff428c17", size = 35892, upload-time = "2025-09-05T12:50:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1a/8836b9f28cee32859ac36c3df85aa03e1ff4598d23ea17ca2e96b5845a8f/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14c7eba8d90c93b0e79c01f0bd92a37b61983c27d6d7d5a3b5defd599113d60e", size = 32898, upload-time = "2025-09-05T12:50:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/8fabdc24baf42defb599714799d8445fe3ae987ec425a26ec8e80ea38f8e/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e64e98077fb30b6cf98073d6c439cd91deb8ebbf8fc62d9dbf52bd38b0c6ac0", size = 34308, upload-time = "2025-09-05T12:50:26.827Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/b9bee9de6c8cdcb3b3a6cb0b3e773afdb86bbbc1665a3bfa424a4294fda2/setproctitle-1.3.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b91387cc0f02a00ac95dcd93f066242d3cca10ff9e6153de7ee07069c6f0f7c8", size = 32536, upload-time = "2025-09-05T12:50:28.5Z" }, + { url = "https://files.pythonhosted.org/packages/37/0c/75e5f2685a5e3eda0b39a8b158d6d8895d6daf3ba86dec9e3ba021510272/setproctitle-1.3.7-cp314-cp314-win32.whl", hash = "sha256:52b054a61c99d1b72fba58b7f5486e04b20fefc6961cd76722b424c187f362ed", size = 12731, upload-time = "2025-09-05T12:50:43.955Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/acddbce90d1361e1786e1fb421bc25baeb0c22ef244ee5d0176511769ec8/setproctitle-1.3.7-cp314-cp314-win_amd64.whl", hash = "sha256:5818e4080ac04da1851b3ec71e8a0f64e3748bf9849045180566d8b736702416", size = 13464, upload-time = "2025-09-05T12:50:45.057Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/20886c8ff2e6d85e3cabadab6aab9bb90acaf1a5cfcb04d633f8d61b2626/setproctitle-1.3.7-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6fc87caf9e323ac426910306c3e5d3205cd9f8dcac06d233fcafe9337f0928a3", size = 18062, upload-time = "2025-09-05T12:50:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/9a/60/26dfc5f198715f1343b95c2f7a1c16ae9ffa45bd89ffd45a60ed258d24ea/setproctitle-1.3.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6134c63853d87a4897ba7d5cc0e16abfa687f6c66fc09f262bb70d67718f2309", size = 13075, upload-time = "2025-09-05T12:50:31.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/9c/980b01f50d51345dd513047e3ba9e96468134b9181319093e61db1c47188/setproctitle-1.3.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1403d2abfd32790b6369916e2313dffbe87d6b11dca5bbd898981bcde48e7a2b", size = 34744, upload-time = "2025-09-05T12:50:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/82cd0c86e6d1c4538e1a7eb908c7517721513b801dff4ba3f98ef816a240/setproctitle-1.3.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7c5bfe4228ea22373e3025965d1a4116097e555ee3436044f5c954a5e63ac45", size = 35589, upload-time = "2025-09-05T12:50:34.13Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/9f6b2a7417fd45673037554021c888b31247f7594ff4bd2239918c5cd6d0/setproctitle-1.3.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:585edf25e54e21a94ccb0fe81ad32b9196b69ebc4fc25f81da81fb8a50cca9e4", size = 37698, upload-time = "2025-09-05T12:50:35.524Z" }, + { url = "https://files.pythonhosted.org/packages/20/92/927b7d4744aac214d149c892cb5fa6dc6f49cfa040cb2b0a844acd63dcaf/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:96c38cdeef9036eb2724c2210e8d0b93224e709af68c435d46a4733a3675fee1", size = 34201, upload-time = "2025-09-05T12:50:36.697Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0c/fd4901db5ba4b9d9013e62f61d9c18d52290497f956745cd3e91b0d80f90/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:45e3ef48350abb49cf937d0a8ba15e42cee1e5ae13ca41a77c66d1abc27a5070", size = 35801, upload-time = "2025-09-05T12:50:38.314Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e3/54b496ac724e60e61cc3447f02690105901ca6d90da0377dffe49ff99fc7/setproctitle-1.3.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1fae595d032b30dab4d659bece20debd202229fce12b55abab978b7f30783d73", size = 33958, upload-time = "2025-09-05T12:50:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/c84bb045ebf8c6fdc7f7532319e86f8380d14bbd3084e6348df56bdfe6fd/setproctitle-1.3.7-cp314-cp314t-win32.whl", hash = "sha256:02432f26f5d1329ab22279ff863c83589894977063f59e6c4b4845804a08f8c2", size = 12745, upload-time = "2025-09-05T12:50:41.377Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/3a5a4f9952972791a9114ac01dfc123f0df79903577a3e0a7a404a695586/setproctitle-1.3.7-cp314-cp314t-win_amd64.whl", hash = "sha256:cbc388e3d86da1f766d8fc2e12682e446064c01cea9f88a88647cfe7c011de6a", size = 13469, upload-time = "2025-09-05T12:50:42.67Z" }, + { url = "https://files.pythonhosted.org/packages/34/8a/aff5506ce89bc3168cb492b18ba45573158d528184e8a9759a05a09088a9/setproctitle-1.3.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:eb440c5644a448e6203935ed60466ec8d0df7278cd22dc6cf782d07911bcbea6", size = 12654, upload-time = "2025-09-05T12:51:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/5b6f2faedd6ced3d3c085a5efbd91380fb1f61f4c12bc42acad37932f4e9/setproctitle-1.3.7-pp310-pypy310_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:502b902a0e4c69031b87870ff4986c290ebbb12d6038a70639f09c331b18efb2", size = 14284, upload-time = "2025-09-05T12:51:18.393Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c0/4312fed3ca393a29589603fd48f17937b4ed0638b923bac75a728382e730/setproctitle-1.3.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f6f268caeabb37ccd824d749e7ce0ec6337c4ed954adba33ec0d90cc46b0ab78", size = 13282, upload-time = "2025-09-05T12:51:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5b/5e1c117ac84e3cefcf8d7a7f6b2461795a87e20869da065a5c087149060b/setproctitle-1.3.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b1cac6a4b0252b8811d60b6d8d0f157c0fdfed379ac89c25a914e6346cf355a1", size = 12587, upload-time = "2025-09-05T12:51:21.195Z" }, + { url = "https://files.pythonhosted.org/packages/73/02/b9eadc226195dcfa90eed37afe56b5dd6fa2f0e5220ab8b7867b8862b926/setproctitle-1.3.7-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1704c9e041f2b1dc38f5be4552e141e1432fba3dd52c72eeffd5bc2db04dc65", size = 14286, upload-time = "2025-09-05T12:51:22.61Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/1be1d2a53c2a91ec48fa2ff4a409b395f836798adf194d99de9c059419ea/setproctitle-1.3.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b08b61976ffa548bd5349ce54404bf6b2d51bd74d4f1b241ed1b0f25bce09c3a", size = 13282, upload-time = "2025-09-05T12:51:24.094Z" }, +] + [[package]] name = "setuptools" version = "82.0.1" @@ -6569,6 +6744,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/21/64/8b7b262febafab861f9c2bb10a33131a904e9152a7141b61197885851a09/together-2.16.0-py3-none-any.whl", hash = "sha256:cc01b67a97e88f023cd17789c0a3ff944fd33848aa7cbc90eb4f4537a2027558", size = 403968, upload-time = "2026-05-23T15:19:23.281Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.1"