From 990e842bac2b8b68279df893018d3f6e8b044692 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 00:00:28 -0700 Subject: [PATCH 01/31] feat(ai): back the Agent with LangChain/LangGraph, keep the public API intact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swap the per-provider SDK internals of ai/agent.py (_run/_stream over the anthropic/openai/google SDKs) for a single LangChain/LangGraph backend: init_chat_model builds the chat model and create_agent drives the tool loop, with the final AIMessage mapped back to AgentResponse. The user-facing surface is unchanged — prompt/stream/fake/assert_prompted/assert_not_prompted/reset, the lifecycle hooks, and the decorators keep identical signatures. - _build_model() is the seam tests patch to inject a fake chat model. - _build_messages() now renders attachments via Document.to_langchain_block(). - Add Document.to_langchain_block(): inline text, base64 image/file blocks. - Add ai/fakes.py fake_chat_model(): replays scripted AIMessage turns through a GenericFakeChatModel (bind_tools no-op) so the real create_agent loop runs offline; exported from the ai package root. - New optional [langgraph] extra (langchain + langchain-core + langgraph). The 23 tests in tests/ai/test_agent_fake.py stay green and unmodified (fake() short-circuits before the backend). Adds tests/ai/test_agent_langgraph_backend.py exercising the real loop offline: simple reply, full tool-calling loop, usage mapping, attachment blocks, provider mapping, and streaming. --- fastapi_startkit/pyproject.toml | 9 + .../src/fastapi_startkit/ai/__init__.py | 2 + .../src/fastapi_startkit/ai/agent.py | 351 ++++------- .../src/fastapi_startkit/ai/document.py | 13 + .../src/fastapi_startkit/ai/fakes.py | 59 ++ .../tests/ai/test_agent_langgraph_backend.py | 154 +++++ fastapi_startkit/uv.lock | 567 ++++++++++++++++-- 7 files changed, 863 insertions(+), 292 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/fakes.py create mode 100644 fastapi_startkit/tests/ai/test_agent_langgraph_backend.py diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 84f9d113..5b1b05dd 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -53,6 +53,12 @@ ai = [ "google-generativeai>=0.8.0", ] +langgraph = [ + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", +] + [dependency-groups] dev = [ "dumpdie>=1.5.0", @@ -68,6 +74,9 @@ dev = [ "sqlalchemy[asyncio]>=2.0.38", "fastapi[standard]>=0.124.4", "faker>=40.13.0", + "langchain>=1.0.0", + "langchain-core>=1.0.0", + "langgraph>=1.0.0", ] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 76c04f99..2e0f52dc 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -22,6 +22,7 @@ from .config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document +from .fakes import fake_chat_model from .image import Image, ImageResponse from .image_factory import ImageFactory from .providers.ai_provider import AIProvider @@ -38,6 +39,7 @@ "AudioResponse", "AudioFactory", "Document", + "fake_chat_model", "GoogleConfig", "Image", "ImageFactory", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 05a7c664..2e8e79b0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -1,4 +1,16 @@ -"""Agent base class — subclass this and apply decorators to build an AI agent.""" +"""Agent base class — subclass this and apply decorators to build an AI agent. + +The agent runs on LangChain/LangGraph: :meth:`Agent.prompt` builds a chat model +with ``init_chat_model`` and drives a ``create_agent`` loop (tools included), +while :meth:`Agent.stream` streams tokens straight from the model. The public +surface — ``prompt``/``stream``/``fake``/``assert_prompted``/``reset`` plus the +lifecycle hooks and decorators — is provider-agnostic; only the backend changed. + +Real calls need the ``langgraph`` extra plus the relevant provider integration +(e.g. ``langchain-anthropic``). Tests never need them: :meth:`fake` short-circuits +before the backend, and :func:`fastapi_startkit.ai.fakes.fake_chat_model` drives +the full agent loop offline. +""" from __future__ import annotations @@ -38,6 +50,13 @@ class Agent: "google": "gemini-2.0-flash", } + # Map the agent's provider name to the LangChain ``init_chat_model`` provider id. + _LANGCHAIN_PROVIDERS: dict[str, str] = { + "anthropic": "anthropic", + "openai": "openai", + "google": "google_genai", + } + def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} self._call_log: list[dict] = [] @@ -202,6 +221,16 @@ def _get_provider_options(self, override: dict | None = None) -> dict: options.update(provider_specific) return options + def _resolve_api_key(self, provider_name: str) -> str | None: + """Try Config.get("ai") first, fallback to None (the model reads its env var).""" + try: + from fastapi_startkit.facades.Config import Config # noqa: PLC0415 + + ai_config = Config.get("ai") + return ai_config.providers[provider_name].key or None + except Exception: + return None + def _build_messages( self, message: str, @@ -224,275 +253,97 @@ def _build_messages( if attachments: content: Any = [{"type": "text", "text": message}] for doc in attachments: - if self._provider == "anthropic": - content.append(doc.to_anthropic_block()) - else: - content.append(doc.to_openai_block()) + content.append(doc.to_langchain_block()) history.append({"role": "user", "content": content}) else: history.append({"role": "user", "content": message}) return resolved_system, history - def _run( - self, - message: str, - system: str | None = None, - model: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, - ) -> AgentResponse: - resolved_system, messages = self._build_messages(message, system, extra_messages, attachments) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - return self._run_anthropic(resolved_system, messages, resolved_model, options) - if self._provider == "openai": - return self._run_openai(resolved_system, messages, resolved_model, options) - if self._provider == "google": - return self._run_google(resolved_system, messages, resolved_model, options) - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: + """Build a LangChain chat model for this agent. - def _stream( - self, - message: str, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, - ) -> Iterator[str]: - resolved_system, messages = self._build_messages(message, system) - resolved_model = self._resolve_model(model) - options = self._get_provider_options(provider_options) - - if self._provider == "anthropic": - yield from self._stream_anthropic(resolved_system, messages, resolved_model, options) - elif self._provider == "openai": - yield from self._stream_openai(resolved_system, messages, resolved_model, options) - elif self._provider == "google": - yield from self._stream_google(resolved_system, messages, resolved_model, options) - else: - raise ValueError(f"Unsupported provider: {self._provider!r}. Use 'anthropic', 'openai', or 'google'.") + This is the seam tests patch to inject a fake chat model (see + :func:`fastapi_startkit.ai.fakes.fake_chat_model`). + """ + from langchain.chat_models import init_chat_model # noqa: PLC0415 - # ── Anthropic ────────────────────────────────────────────────────────── + provider = self._LANGCHAIN_PROVIDERS.get(self._provider, self._provider) + kwargs: dict[str, Any] = {"model_provider": provider} - def _run_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - resp = client.messages.create(**params) - content = "".join(b.text for b in resp.content if hasattr(b, "text")) - return AgentResponse( - content=content, - usage={"input": resp.usage.input_tokens, "output": resp.usage.output_tokens}, - raw=resp, - ) + api_key = self._resolve_api_key(self._provider) + if api_key: + kwargs["api_key"] = api_key + if self._max_tokens: + kwargs["max_tokens"] = self._max_tokens + if self._top_p != 1.0: + kwargs["top_p"] = self._top_p + if self._timeout: + kwargs["timeout"] = self._timeout + kwargs.update(self._get_provider_options(provider_options)) - def _stream_anthropic( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from anthropic import Anthropic # noqa: PLC0415 - - api_key = self._resolve_api_key("anthropic") - client = Anthropic(api_key=api_key) - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": messages, - **options, - } - if system: - params["system"] = system - - with client.messages.stream(**params) as stream: - for text in stream.text_stream: - yield text - - # ── OpenAI ───────────────────────────────────────────────────────────── - - def _run_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> AgentResponse: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - **options, - } - resp = client.chat.completions.create(**params) - content = resp.choices[0].message.content or "" - return AgentResponse( - content=content, - usage={ - "input": resp.usage.prompt_tokens if resp.usage else 0, - "output": resp.usage.completion_tokens if resp.usage else 0, - }, - raw=resp, - ) + return init_chat_model(self._resolve_model(model), **kwargs) - def _stream_openai( - self, - system: str | None, - messages: list[dict], - model: str, - options: dict, - ) -> Iterator[str]: - from openai import OpenAI # noqa: PLC0415 - - api_key = self._resolve_api_key("openai") - client = OpenAI(api_key=api_key) - all_messages: list[dict] = [] - if system: - all_messages.append({"role": "system", "content": system}) - all_messages.extend(messages) - - params: dict[str, Any] = { - "model": model, - "max_tokens": self._max_tokens, - "messages": all_messages, - "stream": True, - **options, - } - for chunk in client.chat.completions.create(**params): - delta = chunk.choices[0].delta.content - if delta: - yield delta + def _to_agent_response(self, result: Any) -> AgentResponse: + """Map a ``create_agent`` invoke result to an AgentResponse.""" + messages = result.get("messages", []) if isinstance(result, dict) else [] + final = messages[-1] if messages else result - def _resolve_api_key(self, provider_name: str) -> str | None: - """Try Config.get("ai") first, fallback to None (SDK reads env var).""" - try: - from fastapi_startkit.facades.Config import Config # noqa: PLC0415 + content = getattr(final, "content", "") + if not isinstance(content, str): + content = str(content) - ai_config = Config.get("ai") - return ai_config.providers[provider_name].key or None - except Exception: - return None + tool_calls = list(getattr(final, "tool_calls", None) or []) - # ── Google ───────────────────────────────────────────────────────────── + usage: dict[str, Any] = {} + meta = getattr(final, "usage_metadata", None) + if meta: + usage = {"input": meta.get("input_tokens", 0), "output": meta.get("output_tokens", 0)} - def _run_google( + return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) + + def _run( self, - system: str | None, - messages: list[dict], - model: str, - options: dict, + message: str, + system: str | None = None, + model: str | None = None, + extra_messages: list[dict] | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: - import google.generativeai as genai # noqa: PLC0415 + from langchain.agents import create_agent # noqa: PLC0415 - api_key = self._resolve_api_key("google") - if api_key: - genai.configure(api_key=api_key) + resolved_system, history = self._build_messages(message, system, extra_messages, attachments) + chat_model = self._build_model(model, provider_options) - generation_config: dict[str, Any] = {} - if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens - if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) + agent_kwargs: dict[str, Any] = {"tools": self.tools()} + if resolved_system: + agent_kwargs["system_prompt"] = resolved_system + schema = self.schema() + if schema is not None: + agent_kwargs["response_format"] = schema - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) + agent = create_agent(chat_model, **agent_kwargs) + result = agent.invoke({"messages": history}, {"recursion_limit": self._max_steps * 2 + 1}) + return self._to_agent_response(result) - google_messages = _to_google_messages(messages) - response = google_model.generate_content(google_messages) - content = response.text if hasattr(response, "text") else "" - usage: dict[str, Any] = {} - if hasattr(response, "usage_metadata"): - meta = response.usage_metadata - usage = { - "input": getattr(meta, "prompt_token_count", 0), - "output": getattr(meta, "candidates_token_count", 0), - } - return AgentResponse(content=content, usage=usage, raw=response) - - def _stream_google( + def _stream( self, - system: str | None, - messages: list[dict], - model: str, - options: dict, + message: str, + system: str | None = None, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: - import google.generativeai as genai # noqa: PLC0415 - - api_key = self._resolve_api_key("google") - if api_key: - genai.configure(api_key=api_key) - - generation_config: dict[str, Any] = {} - if self._max_tokens: - generation_config["max_output_tokens"] = self._max_tokens - if self._top_p != 1.0: - generation_config["top_p"] = self._top_p - generation_config.update(options) - - google_model = genai.GenerativeModel( - model_name=model, - system_instruction=system, - generation_config=generation_config if generation_config else None, - ) - - google_messages = _to_google_messages(messages) - for chunk in google_model.generate_content(google_messages, stream=True): - if chunk.text: - yield chunk.text - - -# ─── Utilities ───────────────────────────────────────────────────────────────── - - -def _to_google_messages(messages: list[dict]) -> list[dict]: - """ - Convert OpenAI-style messages to Google GenerativeAI content format. - Maps 'assistant' role → 'model'; omits 'system' (handled via system_instruction). - """ - result = [] - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - if role == "system": - continue # system_instruction is set at model-construction level - google_role = "model" if role == "assistant" else "user" - if isinstance(content, list): - # Multi-part content — extract text parts only - text = " ".join(p.get("text", "") for p in content if isinstance(p, dict) and "text" in p) - result.append({"role": google_role, "parts": [{"text": text}]}) - else: - result.append({"role": google_role, "parts": [{"text": str(content)}]}) - return result + resolved_system, history = self._build_messages(message, system) + chat_model = self._build_model(model, provider_options) + + lc_messages: list[dict] = [] + if resolved_system: + lc_messages.append({"role": "system", "content": resolved_system}) + lc_messages.extend(history) + + for chunk in chat_model.stream(lc_messages): + text = getattr(chunk, "content", "") + if not text: + continue + yield text if isinstance(text, str) else str(text) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py index 9ac7fdbb..574a5876 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/document.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py @@ -137,3 +137,16 @@ def to_openai_block(self) -> dict: "type": "text", "text": f"[Document: {self.name}]\n{self.content}", } + + def to_langchain_block(self) -> dict: + """Return a LangChain content block for this document. + + Text (or ``text/*`` media) is inlined as a labelled text part; everything + else becomes a base64 ``image``/``file`` block the model reads natively. + """ + is_text = isinstance(self.content, str) or self.media_type.startswith("text/") + if is_text: + text = self.content if isinstance(self.content, str) else self.content.decode("utf-8", "replace") + return {"type": "text", "text": f"[Document: {self.name}]\n{text}"} + block_type = "image" if self.media_type.startswith("image/") else "file" + return {"type": block_type, "base64": self.to_base64(), "mime_type": self.media_type} diff --git a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py new file mode 100644 index 00000000..430f02a9 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py @@ -0,0 +1,59 @@ +"""LangChain test helpers — drive a real agent loop offline, without a provider. + +:func:`fake_chat_model` returns a chat model that replays a scripted sequence of +assistant turns. Inject it into an :class:`~fastapi_startkit.ai.Agent` by patching +``_build_model`` so :meth:`Agent.prompt` runs the genuine ``create_agent`` loop — +tool calls included — with no network. Requires the ``langgraph`` extra:: + + pip install "fastapi-startkit[langgraph]" + +Example — exercise a tool-calling agent end to end:: + + from langchain_core.messages import AIMessage, ToolCall + from fastapi_startkit.ai import fake_chat_model + + model = fake_chat_model([ + AIMessage(content="", tool_calls=[ + ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call"), + ]), + AIMessage(content="Here is a Python Developer role at Shopify."), + ]) + agent = JobAssistant() + agent._build_model = lambda *a, **k: model + response = agent.prompt("find me a python job") + assert response.content == "Here is a Python Developer role at Shopify." +""" + +from __future__ import annotations + +from typing import Any, Iterable + + +def _require_langchain(): + try: + from langchain_core.language_models.fake_chat_models import GenericFakeChatModel + from langchain_core.messages import AIMessage + except ImportError as exc: # pragma: no cover - exercised only without the extra + raise ImportError( + "The agent test harness requires the 'langgraph' extra. " + 'Install it with: pip install "fastapi-startkit[langgraph]"' + ) from exc + return GenericFakeChatModel, AIMessage + + +def fake_chat_model(turns: Iterable[Any]): + """Return a fake chat model that replays ``turns`` in order. + + Each turn is an ``AIMessage`` (which may carry ``tool_calls``) or a ``str`` + (shorthand for ``AIMessage(content=...)``). The scripted turns already encode + the model's decisions, so ``bind_tools`` is a no-op — the bound tool schemas + don't change what the fake says next. + """ + generic_model, ai_message = _require_langchain() + + class _FakeChatModel(generic_model): + def bind_tools(self, tools, **kwargs): + return self + + normalized = [t if isinstance(t, ai_message) else ai_message(content=str(t)) for t in turns] + return _FakeChatModel(messages=iter(normalized)) diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py new file mode 100644 index 00000000..895d8b08 --- /dev/null +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -0,0 +1,154 @@ +"""The Agent backend runs on LangGraph (create_agent), tested offline. + +These exercise the real ``_run``/``_stream`` path — no fake() short-circuit — by +injecting a scripted fake chat model through the ``_build_model`` seam. The public +API is unchanged; ``prompt()`` still returns an AgentResponse, ``stream()`` still +yields strings. +""" + +from langchain_core.messages import AIMessage, ToolCall +from langchain_core.tools import tool + +from fastapi_startkit.ai import Document, fake_chat_model +from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.response import AgentResponse + + +def _with_model(agent: Agent, turns) -> Agent: + """Patch the agent's model seam to replay scripted turns offline.""" + model = fake_chat_model(turns) + agent._build_model = lambda *a, **k: model # type: ignore[method-assign] + return agent + + +@tool +def search_jobs(query: str) -> str: + """Search the job board for roles matching the query.""" + return "Python Developer at Shopify" + + +class JobAssistant(Agent): + def messages(self): + return [{"role": "system", "content": "You help users find jobs."}] + + def tools(self): + return [search_jobs] + + +# ─── prompt() drives the create_agent loop ──────────────────────────────────── + + +def test_prompt_returns_agent_response_from_langgraph(): + agent = _with_model(Agent(), [AIMessage(content="hello back")]) + + result = agent.prompt("hi there") + + assert isinstance(result, AgentResponse) + assert result.content == "hello back" + agent.assert_prompted() + + +def test_prompt_runs_a_full_tool_calling_loop(): + agent = _with_model( + JobAssistant(), + [ + AIMessage( + content="", + tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], + ), + AIMessage(content="Here is a Python Developer role at Shopify."), + ], + ) + + result = agent.prompt("find me a python job") + + assert result.content == "Here is a Python Developer role at Shopify." + # The loop ran: user → AI(tool_call) → tool result → AI(final). + assert len(result.raw["messages"]) == 4 + + +def test_prompt_maps_usage_metadata(): + reply = AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}) + agent = _with_model(Agent(), [reply]) + + result = agent.prompt("anything") + + assert result.usage == {"input": 11, "output": 7} + + +# ─── attachments render as LangChain blocks ─────────────────────────────────── + + +def test_attachments_are_built_as_langchain_blocks(): + agent = Agent() + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + _system, history = agent._build_messages("Summarise this report.", attachments=[doc]) + + user_content = history[-1]["content"] + assert user_content[0] == {"type": "text", "text": "Summarise this report."} + assert user_content[1]["type"] == "text" + assert "q3-report.txt" in user_content[1]["text"] + + +def test_binary_attachment_becomes_a_file_block(): + agent = Agent() + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") + + _system, history = agent._build_messages("Summarise", attachments=[doc]) + + block = history[-1]["content"][1] + assert block["type"] == "file" + assert block["mime_type"] == "application/pdf" + assert block["base64"] == doc.to_base64() + + +def test_prompt_with_attachment_returns_reply(): + agent = _with_model(JobAssistant(), [AIMessage(content="Summarised.")]) + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + result = agent.prompt("Summarise this report.", attachments=[doc]) + + assert result.content == "Summarised." + + +# ─── provider mapping + model resolution ────────────────────────────────────── + + +def test_google_provider_maps_to_langchain_google_genai(): + class GoogleAgent(Agent): + _provider = "google" + + assert GoogleAgent._LANGCHAIN_PROVIDERS["google"] == "google_genai" + + +def test_resolve_model_falls_back_to_provider_default(): + assert Agent()._resolve_model() == "claude-sonnet-4-6" + + class OpenAIAgent(Agent): + _provider = "openai" + + assert OpenAIAgent()._resolve_model() == "gpt-4o" + + +# ─── stream() pulls tokens from the model ───────────────────────────────────── + + +def test_stream_yields_tokens_from_the_model(): + agent = _with_model(Agent(), [AIMessage(content="streamed reply")]) + + chunks = list(agent.stream("hello")) + + assert "".join(chunks) == "streamed reply" + agent.assert_prompted(times=1) + + +# ─── fake_chat_model accepts plain strings ──────────────────────────────────── + + +def test_fake_chat_model_accepts_string_shorthand(): + agent = _with_model(Agent(), ["plain string turn"]) + + result = agent.prompt("anything") + + assert result.content == "plain string turn" diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index 8b674dea..d6b41d3d 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -623,6 +623,11 @@ inertia = [ { name = "jinja2" }, { name = "markupsafe" }, ] +langgraph = [ + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, +] mysql = [ { name = "aiomysql" }, ] @@ -645,6 +650,9 @@ dev = [ { name = "faker" }, { name = "fastapi", extra = ["standard"] }, { name = "itsdangerous" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -669,6 +677,9 @@ requires-dist = [ { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, + { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, @@ -676,7 +687,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai", "langgraph"] [package.metadata.requires-dev] dev = [ @@ -687,6 +698,9 @@ dev = [ { name = "faker", specifier = ">=40.13.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.124.4" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "langchain", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langgraph", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, @@ -1230,6 +1244,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" }, ] +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + [[package]] name = "keyring" version = "25.7.0" @@ -1247,6 +1282,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] +[[package]] +name = "langchain" +version = "1.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/a2/91a7197c604a3ce1b774b3c10dd114c3c745c6186a304fc2573b3f94d400/langchain-1.3.11.tar.gz", hash = "sha256:f3cf9cd4d2329b1a03eb8fd92b9d73e4e58a4d52570d67725fc77fbe0f104b32", size = 633374, upload-time = "2026-06-22T23:00:33.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/a4/3a181967294f8876362cc4ba36840d50b8286fa23bb3f5e602b69eb3cb1e/langchain-1.3.11-py3-none-any.whl", hash = "sha256:7ae011f95a09b22feea1e8ae4e43f0b6164aebf4c61b8ad845b45f72ff3a90a2", size = 133639, upload-time = "2026-06-22T23:00:31.619Z" }, +] + +[[package]] +name = "langchain-core" +version = "1.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langchain-protocol" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "uuid-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" }, +] + +[[package]] +name = "langchain-protocol" +version = "0.0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" }, +] + +[[package]] +name = "langgraph" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-prebuilt" }, + { name = "langgraph-sdk" }, + { name = "pydantic" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "ormsgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" }, +] + +[[package]] +name = "langgraph-prebuilt" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-protocol" }, + { name = "orjson" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" }, +] + +[[package]] +name = "langsmith" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "uuid-utils" }, + { name = "websockets" }, + { name = "xxhash" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/34/a77abacdece10440fa04d8b2afd884708a98f62ff271ee79d75bcb8bb9bc/langsmith-0.9.0.tar.gz", hash = "sha256:21b381462ee44713dd5e2163b7db44fb28fde65146aad27aeabd778c464da0f0", size = 4556365, upload-time = "2026-06-22T15:37:18.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/64/d411be633d1c976955a09f6b3c58fbe70592d1370d262d171f7daf7e3793/langsmith-0.9.0-py3-none-any.whl", hash = "sha256:5eeccc36ff956946df8510a2b3b5a87d36c44f11bfb2e5205e9cf03d7b65ec9c", size = 578496, upload-time = "2026-06-22T15:37:16.42Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1393,6 +1554,98 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, ] +[[package]] +name = "orjson" +version = "3.11.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" }, + { url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" }, + { url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" }, + { url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, + { url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" }, + { url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" }, + { url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" }, + { url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" }, +] + +[[package]] +name = "ormsgpack" +version = "1.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, + { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, + { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, + { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, + { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, + { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, + { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, + { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, + { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, + { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, + { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, +] + [[package]] name = "packaging" version = "26.1" @@ -2110,6 +2363,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tqdm" version = "4.68.2" @@ -2205,6 +2467,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uuid-utils" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5a/5da7ae85b38e3eddba0be3e8e4328f90882fe92989728e6fb552963d4c42/uuid_utils-0.16.2.tar.gz", hash = "sha256:fa637e4f314ad5b59ff6d8e809d506443d68bef30bfaecdfcfe02cce689abb2f", size = 42962, upload-time = "2026-06-18T13:36:48.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/07/294b72a572218bf6e92355203b832b3356c58a7e1e0b92a034497d15bef9/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f064dc54c6abecb09eb104d953bfb079f3c395e0d6b18899979f852d1083549", size = 560726, upload-time = "2026-06-18T13:35:21.053Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3c/1095b6ab574a7fa69136d47bab5a43f320a8f00a0ecb96059fd49b1747b2/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dd7aa18db5cc826d482d876a826fee445839701f81f78567e7c74b4458d57a84", size = 288065, upload-time = "2026-06-18T13:35:22.547Z" }, + { url = "https://files.pythonhosted.org/packages/4c/9d/6404d48fe71def0733c9568d96043b2e1945e2e4205c4eb525db3da42ba3/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc25ad320c9b44c2d3ed33aff4f85b0b277bef4ff79b12c01ee58b52ea44be1d", size = 322946, upload-time = "2026-06-18T13:35:23.648Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/8a009762015a134aa04b5451400e0ec9832ccd598ed4845f9aecb0be6299/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0ca752d51d1004caff65fccffd44b32a26cb099b546e0512cfa09facb683d6c", size = 330186, upload-time = "2026-06-18T13:35:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b0/1613bb98ac11234145aa5bc1de618be536818fef05dec595efb3e2b37097/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8323136bb02355c1b973492ab98b0722206dfdedfb148e4115c35fcdf3889bad", size = 444583, upload-time = "2026-06-18T13:35:25.999Z" }, + { url = "https://files.pythonhosted.org/packages/93/66/83e62c7a152bbbb8b30ac58eaad81f3860ba2fba91a334c50f223f9ce878/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bf8bfdffb22f620635580b17fd178272f30a9841b824b19b935c8db64bf09b6", size = 323064, upload-time = "2026-06-18T13:35:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c1b2faaf3a9d7952f321a9fee3ad74e05b25878bd9b7cd6b0398fe77f279/uuid_utils-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61454f2139424a6cff14eca7849c28b3350f261453b74075aa20fe99592dbb16", size = 347967, upload-time = "2026-06-18T13:35:28.538Z" }, + { url = "https://files.pythonhosted.org/packages/24/d8/cdf79b242e41ae47b7cd617ac5d48f15ce44e81da8000379c757091ae5f8/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:725110434a1d482a639a9ac467a24f1cb531d84ab52e454a13fe145b10b42cae", size = 499187, upload-time = "2026-06-18T13:35:30.042Z" }, + { url = "https://files.pythonhosted.org/packages/be/10/978d5ad82bc0fe7ff02d5be6f1eb83b090849f0a95bf8438593565273b7a/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8197870739a3094990743a80f075fa0b17beafd6c187e5f360e021d90a12a6d1", size = 605696, upload-time = "2026-06-18T13:35:31.289Z" }, + { url = "https://files.pythonhosted.org/packages/3a/28/e382ee44a592e35b80397b493bf3fbbdb8e30a64eaaefc7dabc246aeb253/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e10a02b3a31ed44c7c9a96abde335f5fa222735e73f3081d693414377eb3b016", size = 564975, upload-time = "2026-06-18T13:35:32.419Z" }, + { url = "https://files.pythonhosted.org/packages/a3/d0/f6011dbe4e5d751a8494715e014019cb5b242d8cd6dbec1cfec3d3fb2e81/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd32dbca0792b9683160151dc07fad11b915020eed7c82b43faf0862c2ff06a0", size = 528462, upload-time = "2026-06-18T13:35:33.685Z" }, + { url = "https://files.pythonhosted.org/packages/42/7f/279e6159c37f43feb9dd70218b49a26696cefddaef1db7f4b79895eaf5d5/uuid_utils-0.16.2-cp312-cp312-win32.whl", hash = "sha256:dcdfcab60562d12dd43c1a6f495b1d089e41f0e10fac37d94db285d72b678c23", size = 167047, upload-time = "2026-06-18T13:35:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/47/38/f72f7bed062601448ec2db47351e6c1faccd78fd693bbc6e067299d1fa11/uuid_utils-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:97ee6f5e803ea571f5f6da42efc97d8c5a13f121043680177f8470529b94e855", size = 173821, upload-time = "2026-06-18T13:35:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/37/61/8a025284a31c85b7c0c5319e96868c2c09dea3fc5f676c979a4cd4baf2e7/uuid_utils-0.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:72cfd9ff1e8a7c371a044687e77eb873721c4a9f4814e453439bfba595b84303", size = 172206, upload-time = "2026-06-18T13:35:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/3b48859953ee74fc26628ca5d9e5f848209655a0a8c934032fc596035976/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c19b7d595d12923da682ed13d313c2333b9ebf214e65a47a24927a8a3a81b191", size = 560753, upload-time = "2026-06-18T13:35:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1c/77635489de5454f2a25411030f78d31931dbdc0c86114da00adb9b91f120/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:909e26fa2451c8db31b9ed1d3c8e4ecf513b6d1619db4205997fe99eb6b4ef4f", size = 288056, upload-time = "2026-06-18T13:35:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/8e799537ea458abaefb0f5c3b3b05304d3faf413feb0997605a3f8ae2484/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27271b37fbc6812bb1542c4b8e22ee00223a6bf7f62b1f38d3bcf8e92f6d9acd", size = 323196, upload-time = "2026-06-18T13:35:41.534Z" }, + { url = "https://files.pythonhosted.org/packages/e8/92/4e5b412d4710617fb83ed77b361f5fa6247b99bde2fa6ee07ddf851b59d1/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc4b9d96a2c689d664cf3fc7f7db46b82d2821fb2ce8a4f0798fc0a92c1569f8", size = 330858, upload-time = "2026-06-18T13:35:42.709Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e3/8173202b7cfcfeb4a588c5f8b85d3e2b44973384eb33167ee25c5c78867f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3bf41b696b0fe808df1b4091c70273a52ea033b0fe97341cd67ecd76d22bb3a", size = 444813, upload-time = "2026-06-18T13:35:43.917Z" }, + { url = "https://files.pythonhosted.org/packages/37/0d/c3918356932ce467b11e954d0c93697fb4652cf664957e3d9521f7ece22f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc329be41bb6534ecb03e50596179ab76c7643ced33d13c66967d5ae1869663", size = 322828, upload-time = "2026-06-18T13:35:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/f0/80/4020556682441b62a25b7d07798812115fca97d417a3498d5af6dce36504/uuid_utils-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4125bf6ed3ae443c05e140f8585d174b9d647295b12034d5ec94ae2ae38edefa", size = 347909, upload-time = "2026-06-18T13:35:46.364Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/a1e87e268df98f6740af81abf225532c173a971c64df0258c84b630e35a7/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:840b21e609a9b203eee06bdc73e18397154447a9814a8e78d9b68e5104d9802f", size = 499469, upload-time = "2026-06-18T13:35:47.584Z" }, + { url = "https://files.pythonhosted.org/packages/25/75/5a1f297a09556c27d9617c44ab0510de5f3a70120df236f66b9d0fdd1976/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5119bec75f56bd028d97472f72b1ed723a0d60b09a48017dc70a3cb1892ed081", size = 606160, upload-time = "2026-06-18T13:35:48.963Z" }, + { url = "https://files.pythonhosted.org/packages/7c/de/140f1d2a161320d1ac9073a03b9eb31fe35ae70f56f8971ec1fb45c14a44/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fe600ab7d3d4eb56986e814042c917e728ac92cd8a41f099a6b59b84d8bf9e6", size = 564856, upload-time = "2026-06-18T13:35:50.244Z" }, + { url = "https://files.pythonhosted.org/packages/01/3b/9a5fe6691f8f6d72899cdc2713ffbd845b8c6981eeeab66d98a71b721116/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44020a4532229ccfbba353138539774686350dda71cf4368e257973dd8ba403", size = 528376, upload-time = "2026-06-18T13:35:51.825Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/47c93dcabd00f6749803a00be361c75d7079c78ad5e67077dee63d30b687/uuid_utils-0.16.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:280d4f1f22dd2e79c1cc31ffc7fc26dc3534ffc114dedcdd29cc8489c5ce9c98", size = 98033, upload-time = "2026-06-18T13:35:53.385Z" }, + { url = "https://files.pythonhosted.org/packages/c0/fd/8de85eeb8dd59354ad46e897ab0d0f0fe6bc48702239a6c9f2613f961c8e/uuid_utils-0.16.2-cp313-cp313-win32.whl", hash = "sha256:4942b26ad12c5187bac52b7fb4685040139ff0df9a19cde33e5025326f6180fc", size = 167054, upload-time = "2026-06-18T13:35:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/b5ba393fbe5142eb9d5db23d4b9b16dde2a4e1aee6f2fcb7fadef97e419a/uuid_utils-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:01f81c71cf2185de0707e9d2f248e17025ba50af0acd3cbf51cd8aea96c2e0be", size = 173481, upload-time = "2026-06-18T13:35:55.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/79/4e5d63d605b13201ae9af6fcc36ec77949cccc99486c430c016d8f8ed274/uuid_utils-0.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:c1dbe65ce6d46c5f645356d64bfb2de7564e2426ca8c9b1a0a401d6f7ae5cc22", size = 172197, upload-time = "2026-06-18T13:35:56.817Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/0e5a0c1e1e3243cf5f12efd2b88a33e63c38b6a79483d3c84b2f5e7265cf/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:617955f4b3f649617c0388127d8a257202189d5cc3c720313f8b207df1cdb2a4", size = 566227, upload-time = "2026-06-18T13:35:57.925Z" }, + { url = "https://files.pythonhosted.org/packages/28/b3/2b6f9d6832e939aaf2b2ba89ff70b3994cfa3ae9b14daac3329eb9202ef8/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0aa2569908bdb21ccb216cd6bd06cb934351ee65ea7cd5e351e19f633a99b577", size = 290301, upload-time = "2026-06-18T13:35:59.467Z" }, + { url = "https://files.pythonhosted.org/packages/f5/27/8bb31429884b9f340f964ed70b68bfd81cec61f6e6877633f6a014358e78/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4af7673e84e1ec6029f18d3a0408095c471c4e2691b6e46b4e1f0a2051734ba", size = 325409, upload-time = "2026-06-18T13:36:00.786Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/3b59aa97e788ca4fa46e2a3856ef567b51e03fd7fbf27d39ce36e46478b6/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecadf55ed6b8fb72e7966b52fd02919e7d7bb8e7bffeaf285803b82e774debfb", size = 332071, upload-time = "2026-06-18T13:36:02.043Z" }, + { url = "https://files.pythonhosted.org/packages/1c/21/8c21bf6cf3ce9447b73cee6a38ca63c9bb2f3145259422646bae8e8ddc21/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:026b96b2f1e6b004579e030692d2f6568ccd0b29d40687213c31694abf570c78", size = 447075, upload-time = "2026-06-18T13:36:03.305Z" }, + { url = "https://files.pythonhosted.org/packages/95/43/77e83019effe1a5ab7169a2d4bf1bd654bebd850b81c8a937b96bd6b5c9c/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273679723e88544dd2de0564ab7f2fddfa2270faf05cabfdf63c275be67ec2a1", size = 325061, upload-time = "2026-06-18T13:36:04.972Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a6/7bf6e0165dc191c09bc4e8c011de5463d64c5a651ed38ad6698bfc552a52/uuid_utils-0.16.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5b1a338b92d1eb121e9eaf06ae3db1b9a5cd794ce318a475f6dc6f9e89c3a8", size = 350302, upload-time = "2026-06-18T13:36:06.172Z" }, + { url = "https://files.pythonhosted.org/packages/45/66/260836aaef14b8254bc449b3163fedec06ef0a0bba0d6a999c918479b2f9/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e75f9429d4533ce275c98bc68bf47fb237ae7b32c954266dabc5edab0c7d682e", size = 501834, upload-time = "2026-06-18T13:36:07.469Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/84c1542bf8c465b456f742318ad83eace63551e7f603b06c817b726670af/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f3cca9ca5e2c2dfd7b885f0d34c10b993a070d3593f3cdfef785195da36fb0f", size = 607406, upload-time = "2026-06-18T13:36:08.913Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/1024c22657a0c0572c4fd5189fad3127cb46731fb26fad3be1e8a4a64972/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ef8c561fdf88fec205e3d54037824cfe2addce16b509a8d2ecb69daa904cbb7", size = 567623, upload-time = "2026-06-18T13:36:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/15/0e/ad7424a6444e3e108a22781c2e164e82752da5db23ccc5cba8b4470c3164/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e3acb5e1451232381daea01645a98c69de4bb9ad88d77a1f7c1df4d83d54e62", size = 530659, upload-time = "2026-06-18T13:36:11.649Z" }, + { url = "https://files.pythonhosted.org/packages/69/60/cf1666d0dbd6fa869b6de3b85a17254ff0ab10ed286fd59366148bf08e89/uuid_utils-0.16.2-cp314-cp314-win32.whl", hash = "sha256:b5f8e7d0bb2c6e6180176237f92d2e949626e04fcf701c49d73f128e1f64e1d1", size = 169272, upload-time = "2026-06-18T13:36:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5e/111908bdc7287b2589e9a9f10be8e0358844fb4a0554677cbbe0ade49766/uuid_utils-0.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:bf922bad7df257336b594d316a1657df569860bb5389602919001fa6fb17f06e", size = 175435, upload-time = "2026-06-18T13:36:14.114Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5d/b3bd7415622060dd17d587545e3c037f83dc0dffb8880ac798ca7936f630/uuid_utils-0.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:fad82e6482129c58ba9b00da6c247ab6e767645ab17981599229cce19d7b2ce9", size = 173553, upload-time = "2026-06-18T13:36:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/92/43/401acf6fc0e0665dd11a095a28f6d22708c6f8f148c326cfc5b0b1ae9882/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0609e7e906c08386b7f33141254df05dcab24f1c4884150988dc7a287516aca", size = 567548, upload-time = "2026-06-18T13:36:16.848Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/cc2bb8273d414d651acafccc3705a8843c130a541fcce65fbeaac22266ba/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ad2adeb941292fe02e1e5c70b80a5746c45b1b77594506c2a1421455d8384f9", size = 291348, upload-time = "2026-06-18T13:36:18.145Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a8/fdadd7ada0de53dbc03f719da0948cc275abd24d8013a26e42e50d3665c1/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d906c00f965d5c5f4812d0086dc49bf813285ea84c97e8816405200e146f805b", size = 325495, upload-time = "2026-06-18T13:36:19.417Z" }, + { url = "https://files.pythonhosted.org/packages/16/42/e397a1eda06b20dd3a206e3a55b346ff2caad23906586801a87359530864/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a59205fc15463dd0f978f14df14307737e3d4e8ef4aefa29a9d0fa766d84d16b", size = 332301, upload-time = "2026-06-18T13:36:20.747Z" }, + { url = "https://files.pythonhosted.org/packages/46/be/12d3df7bd824e3ce71630c022184a5aecfea92b0a7fa70459542b237777a/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac82500329ffaf2788dac36cf133e1e4e23b6d5e1118274ea6749c3b512f4f1", size = 446760, upload-time = "2026-06-18T13:36:22.198Z" }, + { url = "https://files.pythonhosted.org/packages/f7/10/0c5d1dd6874fa35e2cb66a8499ce303eb8678bef226951182603bd30017d/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d8257329f26905f009aed694bd3b17f334f43748b03134dc7bc99d6c5b4e371", size = 325781, upload-time = "2026-06-18T13:36:23.566Z" }, + { url = "https://files.pythonhosted.org/packages/04/e2/9ebb8414875e5c14737fa7145a023458c9b15754f1d129cefe7824197256/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e04b5c10c6fcf9d9801084d1e86c9d7ada7eb48fe07ee4ae5e7fe5b1a852db8a", size = 351189, upload-time = "2026-06-18T13:36:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5c/168d1f4d30b33c08365debfe4176c2f713a0940f1f11a64128a186d050c6/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3d4805c4739dd06d539f8f4fa94f5aaf26eca4b3ece1ef134d4ff904c6b08dcf", size = 501866, upload-time = "2026-06-18T13:36:26.31Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8d/003865d5ed5bf82ece80bd61edb2692985f7548051749fd10f34edb16705/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:76632d2e16e26de777851ec07961ceaea14e65167d0603a0b17fb169fa9ca37b", size = 607632, upload-time = "2026-06-18T13:36:27.704Z" }, + { url = "https://files.pythonhosted.org/packages/ea/52/6102f21f28323b27122a6aa3d4cea183b4fc401868c5c40767e1b9f53beb/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c02f85f49c9c2abbf247a8622458c30232332a28711755aa191da5f38015af6", size = 568216, upload-time = "2026-06-18T13:36:29.377Z" }, + { url = "https://files.pythonhosted.org/packages/68/50/644e4e55f47048d12bc20665fac85bc1fecbed9c892acfb91626abf8ad8d/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f668035ea9faa763e8f1ea42040e8439db88cf2517056d47c348a62a257a1d02", size = 531370, upload-time = "2026-06-18T13:36:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5d/d98d99f601d70cc00287dce5aadef9c199912f0d64343962542f35e7db59/uuid_utils-0.16.2-cp314-cp314t-win32.whl", hash = "sha256:62b8841895eff1c0afbaf5f0050411667231160478c8ff9f411742abffd3b619", size = 169424, upload-time = "2026-06-18T13:36:32.246Z" }, + { url = "https://files.pythonhosted.org/packages/a6/af/c0d482bdd637a8a742d3274cec462b770919f032e179216f2fc2851afaf9/uuid_utils-0.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e9064805881c30dd80a4189a0da7130e3d684de353ea36edd99c1b994bdf429e", size = 175544, upload-time = "2026-06-18T13:36:33.75Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/aff8b0456e8a63672fa89ea9c773f7547a31ff7b596a40f226bf148921a3/uuid_utils-0.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:3324bac95084e63e28553c92fac5a0394c636a76e03e50a7dab0c0bbddf87fa5", size = 173972, upload-time = "2026-06-18T13:36:35.076Z" }, +] + [[package]] name = "uvicorn" version = "0.46.0" @@ -2333,45 +2660,201 @@ wheels = [ [[package]] name = "websockets" -version = "16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, - { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, - { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, - { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, - { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, - { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, - { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, - { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, - { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "xxhash" +version = "3.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" }, + { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" }, + { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" }, + { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" }, + { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" }, + { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" }, + { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" }, + { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" }, + { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" }, + { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" }, + { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" }, + { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" }, + { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" }, + { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" }, + { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" }, + { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" }, + { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" }, + { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" }, + { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" }, + { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" }, + { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" }, + { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" }, + { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" }, + { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" }, + { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" }, + { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" }, + { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" }, + { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" }, + { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" }, + { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" }, + { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" }, + { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" }, + { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" }, + { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" }, + { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" }, + { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" }, + { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" }, + { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, ] From 478e72eef6eb8c6d5dd5dfdcfcc64da5fc657334 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 01:48:52 -0700 Subject: [PATCH 02/31] refactor(ai): config package + Lab provider/model resolution; wire Agent through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn ai/config.py into a config package and resolve models/providers through a new Lab helper instead of hardcoded dicts on the Agent. - ai/config/: split provider dataclasses (config.py) from the top-level AIConfig (ai.py), add config/__init__.py re-exporting them, and give each provider a models map keyed by modality (default / default_image / default_audio / default_transcribe). Fix the draft's circular import (AIConfig imported the provider configs from the package root mid-init) and the placeholder model values (google text default, elevenlabs models). - AIConfig selects the default provider per modality: default (text), default_image, default_audio, default_transcribe. image.py/audio.py now read default_image/default_audio (was image_provider/audio_provider). - ai/lab.py: Lab(StrEnum) + ModelType resolve the provider, default model, and the ":" URL from Config (google → google_genai). - Agent: _resolve_model() and _build_model() now go through Lab; removed the stale _DEFAULT_MODELS/_LANGCHAIN_PROVIDERS references and the dead _execute_tool() (create_agent runs tools itself). - Tests: drop the _build_model monkeypatch helper; the backend tests now patch the real langchain.chat_models.init_chat_model seam via pytest monkeypatch. Add test_lab.py; update image/audio provider-selection mocks. The 23 tests in tests/ai/test_agent_fake.py stay green and unmodified. --- .../src/fastapi_startkit/ai/__init__.py | 21 +-- .../src/fastapi_startkit/ai/agent.py | 35 ++--- .../src/fastapi_startkit/ai/audio.py | 4 +- .../src/fastapi_startkit/ai/audio_factory.py | 2 +- .../fastapi_startkit/ai/config/__init__.py | 10 ++ .../src/fastapi_startkit/ai/config/ai.py | 24 +++ .../ai/{ => config}/config.py | 47 +++--- .../src/fastapi_startkit/ai/image.py | 4 +- .../src/fastapi_startkit/ai/image_factory.py | 2 +- .../src/fastapi_startkit/ai/lab.py | 69 +++++++++ .../tests/ai/test_agent_langgraph_backend.py | 143 +++++++++--------- fastapi_startkit/tests/ai/test_audio.py | 4 +- fastapi_startkit/tests/ai/test_image.py | 2 +- fastapi_startkit/tests/ai/test_lab.py | 51 +++++++ 14 files changed, 279 insertions(+), 139 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/ai.py rename fastapi_startkit/src/fastapi_startkit/ai/{ => config}/config.py (55%) create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/lab.py create mode 100644 fastapi_startkit/tests/ai/test_lab.py diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 2e0f52dc..74117630 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -1,25 +1,7 @@ -"""FastAPI Startkit AI module. - -Provides a LangGraph-powered declarative API for building AI agents backed -by Anthropic, OpenAI, or Google provider SDKs. - -Also exposes a Laravel-style fluent API for image generation and text-to-speech:: - - from fastapi_startkit.ai import Image, Audio, Document - - image = await Image.of("A donut on a counter").generate() - - # With a photo attachment - doc = await Document.from_url("https://example.com/photo.jpg") - image = await Image.of("Make impressionist").attachments([doc]).generate() - - audio = await Audio.of("Hello world").female().generate() -""" - from .agent import Agent from .audio import Audio, AudioResponse from .audio_factory import AudioFactory -from .config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig +from .config import AIConfig, AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document from .fakes import fake_chat_model @@ -39,6 +21,7 @@ "AudioResponse", "AudioFactory", "Document", + "ElevenLabsConfig", "fake_chat_model", "GoogleConfig", "Image", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 2e8e79b0..61c864a1 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -15,11 +15,14 @@ from __future__ import annotations import fnmatch -from typing import Any, Callable, Iterator, Optional, Type +from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Type from .document import Document from .response import AgentResponse, AgentSnapshot +if TYPE_CHECKING: + from .lab import Lab + class Agent: """ @@ -44,25 +47,10 @@ class Agent: _top_p: float = 1.0 _memory_backend: str = "" - _DEFAULT_MODELS: dict[str, str] = { - "anthropic": "claude-sonnet-4-6", - "openai": "gpt-4o", - "google": "gemini-2.0-flash", - } - - # Map the agent's provider name to the LangChain ``init_chat_model`` provider id. - _LANGCHAIN_PROVIDERS: dict[str, str] = { - "anthropic": "anthropic", - "openai": "openai", - "google": "google_genai", - } - def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} self._call_log: list[dict] = [] - # ── Lifecycle — override in subclasses ────────────────────────────────── - def messages(self) -> list[dict]: """Return initial messages / few-shot examples.""" return [] @@ -199,19 +187,17 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) - def _execute_tool(self, name: str, inputs: dict) -> Any: - """Find a tool by function name and call it with the given inputs.""" - for tool in self.tools(): - if callable(tool) and tool.__name__ == name: - return tool(**inputs) - raise ValueError(f"Tool {name!r} not found") + def _lab(self) -> "Lab": + from .lab import Lab # noqa: PLC0415 + + return Lab(self._provider) def _resolve_model(self, override: str | None = None) -> str: if override: return override if self._model: return self._model - return self._DEFAULT_MODELS.get(self._provider, "") + return self._lab().get_model() def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -268,8 +254,7 @@ def _build_model(self, model: str | None = None, provider_options: dict | None = """ from langchain.chat_models import init_chat_model # noqa: PLC0415 - provider = self._LANGCHAIN_PROVIDERS.get(self._provider, self._provider) - kwargs: dict[str, Any] = {"model_provider": provider} + kwargs: dict[str, Any] = {"model_provider": self._lab().get_provider_key()} api_key = self._resolve_api_key(self._provider) if api_key: diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio.py b/fastapi_startkit/src/fastapi_startkit/ai/audio.py index b709daff..a05c9bc7 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio.py @@ -89,7 +89,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Audio: """Fluent builder for text-to-speech generation. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.audio_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_audio` (env: ``AI_AUDIO_PROVIDER``). Defaults to OpenAI TTS. Usage:: @@ -191,7 +191,7 @@ def _resolve_provider(self) -> "AudioFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.audio_provider + provider_name = ai_config.default_audio openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py index 14c817b6..3613e5f3 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`AudioFactory` ABC so that the :class:`~fastapi_startkit.ai.Audio` builder is not hard-wired to a single vendor. Select the active provider via ``AI_AUDIO_PROVIDER`` in your -``.env`` (or ``AIConfig.audio_provider``). +``.env`` (or ``AIConfig.default_audio``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py new file mode 100644 index 00000000..af215946 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py @@ -0,0 +1,10 @@ +from .ai import AIConfig +from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig + +__all__ = [ + "AIConfig", + "AnthropicConfig", + "ElevenLabsConfig", + "GoogleConfig", + "OpenAIConfig", +] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py new file mode 100644 index 00000000..c2883f55 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from fastapi_startkit.environment import env + +from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig + + +@dataclass +class AIConfig: + """Top-level AI configuration — selects the default provider per modality and holds per-provider configs.""" + + default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) + default_image: str = field(default_factory=lambda: env("AI_DEFAULT_IMAGE_PROVIDER", "openai")) + default_audio: str = field(default_factory=lambda: env("AI_DEFAULT_AUDIO_PROVIDER", "openai")) + default_transcribe: str = field(default_factory=lambda: env("AI_DEFAULT_TRANSCRIBE_PROVIDER", "openai")) + + providers: dict = field( + default_factory=lambda: { + "google": GoogleConfig(), + "openai": OpenAIConfig(), + "anthropic": AnthropicConfig(), + "elevenlabs": ElevenLabsConfig(), + } + ) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py similarity index 55% rename from fastapi_startkit/src/fastapi_startkit/ai/config.py rename to fastapi_startkit/src/fastapi_startkit/ai/config/config.py index 16a74206..96bb5c67 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py @@ -1,4 +1,10 @@ -"""AI configuration dataclasses for the FastAPI Startkit AI module.""" +"""Per-provider AI configuration dataclasses. + +Each provider declares its ``driver``, credentials, and a ``models`` map of the +default model per modality (``default`` = text, plus ``default_image`` / +``default_audio`` / ``default_transcribe`` where the provider supports them). +:class:`~fastapi_startkit.ai.lab.Lab` resolves these via the ``Config`` store. +""" from __future__ import annotations @@ -15,6 +21,12 @@ class AnthropicConfig: key: str = field(default_factory=lambda: env("ANTHROPIC_API_KEY", "")) url: str = field(default_factory=lambda: env("ANTHROPIC_BASE_URL", "https://api.anthropic.com")) + models: dict = field( + default_factory=lambda: { + "default": "claude-sonnet-4-6", + } + ) + @dataclass class OpenAIConfig: @@ -24,6 +36,13 @@ class OpenAIConfig: key: str = field(default_factory=lambda: env("OPENAI_API_KEY", "")) url: str = field(default_factory=lambda: env("OPENAI_BASE_URL", "https://api.openai.com/v1")) + models: dict = field( + default_factory=lambda: { + "default": "gpt-4o", + "default_image": "dall-e-3", + } + ) + @dataclass class GoogleConfig: @@ -32,6 +51,13 @@ class GoogleConfig: driver: str = "google" key: str = field(default_factory=lambda: env("GEMINI_API_KEY", "") or env("GOOGLE_API_KEY", "")) + models: dict = field( + default_factory=lambda: { + "default": "gemini-2.0-flash", + "default_image": "imagen-3.0-generate-002", + } + ) + @dataclass class ElevenLabsConfig: @@ -40,22 +66,9 @@ class ElevenLabsConfig: driver: str = "elevenlabs" key: str = field(default_factory=lambda: env("ELEVENLABS_API_KEY", "")) - -@dataclass -class AIConfig: - """Top-level AI configuration — selects the default provider and holds per-provider configs.""" - - default: str = field(default_factory=lambda: env("AI_PROVIDER", "google")) - - providers: dict = field( + models: dict = field( default_factory=lambda: { - "openai": OpenAIConfig(), - "anthropic": AnthropicConfig(), - "google": GoogleConfig(), - "elevenlabs": ElevenLabsConfig(), + "default_audio": "eleven_multilingual_v2", + "default_transcribe": "scribe_v1", } ) - - # Media-generation provider selection - image_provider: str = field(default_factory=lambda: env("AI_IMAGE_PROVIDER", "openai")) - audio_provider: str = field(default_factory=lambda: env("AI_AUDIO_PROVIDER", "openai")) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image.py b/fastapi_startkit/src/fastapi_startkit/ai/image.py index c6794293..ec11e28b 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image.py @@ -90,7 +90,7 @@ def _save_sync(self, name: str, disk: str) -> str: class Image: """Fluent builder for image generation and editing. - The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.image_provider` + The active backend is selected from :attr:`~fastapi_startkit.ai.AIConfig.default_image` (env: ``AI_IMAGE_PROVIDER``). Defaults to OpenAI DALL-E. Usage — text to image:: @@ -198,7 +198,7 @@ def _resolve_provider(self) -> "ImageFactory": ai_config = Config.get("ai") if Config is not None else None # type: ignore[union-attr] if ai_config is None: raise RuntimeError("Config not available") - provider_name = ai_config.image_provider + provider_name = ai_config.default_image openai_cfg = ai_config.providers.get("openai") if openai_cfg: api_key = openai_cfg.key or None diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py index b699dafa..b5bf4ba4 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py @@ -3,7 +3,7 @@ Providers implement the :class:`ImageFactory` ABC so that the :class:`~fastapi_startkit.ai.Image` builder is not hard-wired to a single vendor. Select the active provider via ``AI_IMAGE_PROVIDER`` in your -``.env`` (or ``AIConfig.image_provider``). +``.env`` (or ``AIConfig.default_image``). Supported providers ------------------- diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py new file mode 100644 index 00000000..f0da5a2c --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -0,0 +1,69 @@ +"""Lab — resolve the active provider, model, and LangChain model URL from config. + +The ``ai`` config selects a default provider per modality (``ai.default`` for text, +``ai.default_image`` / ``ai.default_audio`` / ``ai.default_transcribe``) and stores +each provider's default models under ``ai.providers..models``. ``Lab`` reads +those and builds the ``":"`` string LangChain's ``init_chat_model`` +and ``create_agent`` understand. +""" + +from enum import StrEnum + + +def _config(): + from fastapi_startkit import Config # noqa: PLC0415 + + return Config + + +class ModelType(StrEnum): + TEXT = "text" + IMAGE = "image" + AUDIO = "audio" + TRANSCRIBE = "transcribe" + + +def _model_key(model_type: ModelType) -> str: + """The key under a provider's ``models`` map for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +def _provider_field(model_type: ModelType) -> str: + """The ``ai`` config field selecting the default provider for the given modality.""" + return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + + +class Lab(StrEnum): + GOOGLE = "google" + OPENAI = "openai" + ANTHROPIC = "anthropic" + ELEVENLABS = "elevenlabs" + + def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: + """Return ``model`` if given, else this provider's configured default for the modality.""" + return model or _config().get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + + def get_provider_key(self) -> str: + """Map this provider to its LangChain ``init_chat_model`` id.""" + return { + "anthropic": "anthropic", + "openai": "openai", + "google": "google_genai", + "elevenlabs": "elevenlabs", + }[self.value] + + @staticmethod + def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": + """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" + provider = provider or _config().get(f"ai.{_provider_field(model_type)}") + return Lab(provider) + + @staticmethod + def get_model_url( + provider: str | None = None, + model: str | None = None, + model_type: ModelType = ModelType.TEXT, + ) -> str: + """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" + lab = Lab.get_provider(provider, model_type) + return f"{lab.get_provider_key()}:{lab.get_model(model, model_type)}" diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py index 895d8b08..a3cc5b3d 100644 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -1,24 +1,41 @@ """The Agent backend runs on LangGraph (create_agent), tested offline. -These exercise the real ``_run``/``_stream`` path — no fake() short-circuit — by -injecting a scripted fake chat model through the ``_build_model`` seam. The public -API is unchanged; ``prompt()`` still returns an AgentResponse, ``stream()`` still -yields strings. +These exercise the real ``_run``/``_stream`` path — no ``fake()`` short-circuit. +Instead of mutating the agent, we patch the single seam the backend uses to build +a model (``langchain.chat_models.init_chat_model``) with pytest's ``monkeypatch`` +and feed it a scripted :func:`fake_chat_model`. The public API is unchanged: +``prompt()`` still returns an AgentResponse and ``stream()`` still yields strings. """ +import langchain.chat_models as chat_models +import pytest from langchain_core.messages import AIMessage, ToolCall from langchain_core.tools import tool from fastapi_startkit.ai import Document, fake_chat_model from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.config import AIConfig from fastapi_startkit.ai.response import AgentResponse +from fastapi_startkit.application import app -def _with_model(agent: Agent, turns) -> Agent: - """Patch the agent's model seam to replay scripted turns offline.""" - model = fake_chat_model(turns) - agent._build_model = lambda *a, **k: model # type: ignore[method-assign] - return agent +@pytest.fixture(autouse=True) +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + + +@pytest.fixture +def scripted_model(monkeypatch): + """Replace the model the backend builds with a scripted fake, offline.""" + + def install(turns): + model = fake_chat_model(turns) + monkeypatch.setattr(chat_models, "init_chat_model", lambda *a, **k: model) + return model + + return install @tool @@ -38,117 +55,105 @@ def tools(self): # ─── prompt() drives the create_agent loop ──────────────────────────────────── -def test_prompt_returns_agent_response_from_langgraph(): - agent = _with_model(Agent(), [AIMessage(content="hello back")]) +def test_prompt_returns_agent_response(scripted_model): + scripted_model([AIMessage(content="hello back")]) - result = agent.prompt("hi there") + result = Agent().prompt("hi there") assert isinstance(result, AgentResponse) assert result.content == "hello back" - agent.assert_prompted() + Agent().assert_not_prompted() # a fresh instance has its own empty log -def test_prompt_runs_a_full_tool_calling_loop(): - agent = _with_model( - JobAssistant(), +def test_prompt_runs_a_full_tool_calling_loop(scripted_model): + scripted_model( [ AIMessage( content="", tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], ), AIMessage(content="Here is a Python Developer role at Shopify."), - ], + ] ) - result = agent.prompt("find me a python job") + result = JobAssistant().prompt("find me a python job") assert result.content == "Here is a Python Developer role at Shopify." # The loop ran: user → AI(tool_call) → tool result → AI(final). assert len(result.raw["messages"]) == 4 -def test_prompt_maps_usage_metadata(): - reply = AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}) - agent = _with_model(Agent(), [reply]) +def test_prompt_maps_usage_metadata(scripted_model): + scripted_model([AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})]) - result = agent.prompt("anything") + result = Agent().prompt("anything") assert result.usage == {"input": 11, "output": 7} -# ─── attachments render as LangChain blocks ─────────────────────────────────── +def test_build_model_passes_langchain_provider_key(monkeypatch): + captured = {} + def fake_init(model, **kwargs): + captured["model"] = model + captured["provider"] = kwargs.get("model_provider") + return fake_chat_model([AIMessage(content="ok")]) -def test_attachments_are_built_as_langchain_blocks(): - agent = Agent() - doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + monkeypatch.setattr(chat_models, "init_chat_model", fake_init) - _system, history = agent._build_messages("Summarise this report.", attachments=[doc]) + class GoogleAgent(Agent): + _provider = "google" - user_content = history[-1]["content"] - assert user_content[0] == {"type": "text", "text": "Summarise this report."} - assert user_content[1]["type"] == "text" - assert "q3-report.txt" in user_content[1]["text"] + GoogleAgent().prompt("hi") + assert captured["provider"] == "google_genai" + assert captured["model"] == "gemini-2.0-flash" -def test_binary_attachment_becomes_a_file_block(): - agent = Agent() - doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - _system, history = agent._build_messages("Summarise", attachments=[doc]) +# ─── streaming ──────────────────────────────────────────────────────────────── - block = history[-1]["content"][1] - assert block["type"] == "file" - assert block["mime_type"] == "application/pdf" - assert block["base64"] == doc.to_base64() +def test_stream_yields_tokens_from_the_model(scripted_model): + scripted_model([AIMessage(content="streamed reply")]) -def test_prompt_with_attachment_returns_reply(): - agent = _with_model(JobAssistant(), [AIMessage(content="Summarised.")]) - doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + chunks = list(Agent().stream("hello")) - result = agent.prompt("Summarise this report.", attachments=[doc]) + assert "".join(chunks) == "streamed reply" - assert result.content == "Summarised." +# ─── model / message building (unit) ────────────────────────────────────────── -# ─── provider mapping + model resolution ────────────────────────────────────── +def test_resolve_model_falls_back_to_lab_default(): + assert Agent()._resolve_model() == "claude-sonnet-4-6" -def test_google_provider_maps_to_langchain_google_genai(): class GoogleAgent(Agent): _provider = "google" - assert GoogleAgent._LANGCHAIN_PROVIDERS["google"] == "google_genai" + assert GoogleAgent()._resolve_model() == "gemini-2.0-flash" -def test_resolve_model_falls_back_to_provider_default(): - assert Agent()._resolve_model() == "claude-sonnet-4-6" - - class OpenAIAgent(Agent): - _provider = "openai" - - assert OpenAIAgent()._resolve_model() == "gpt-4o" - - -# ─── stream() pulls tokens from the model ───────────────────────────────────── +def test_resolve_model_prefers_explicit_override(): + assert Agent()._resolve_model("my-model") == "my-model" -def test_stream_yields_tokens_from_the_model(): - agent = _with_model(Agent(), [AIMessage(content="streamed reply")]) - - chunks = list(agent.stream("hello")) - - assert "".join(chunks) == "streamed reply" - agent.assert_prompted(times=1) +def test_build_messages_inlines_text_attachment(): + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + _system, history = Agent()._build_messages("Summarise this report.", attachments=[doc]) -# ─── fake_chat_model accepts plain strings ──────────────────────────────────── + user_content = history[-1]["content"] + assert user_content[0] == {"type": "text", "text": "Summarise this report."} + assert user_content[1]["type"] == "text" + assert "q3-report.txt" in user_content[1]["text"] -def test_fake_chat_model_accepts_string_shorthand(): - agent = _with_model(Agent(), ["plain string turn"]) +def test_build_messages_encodes_binary_attachment_as_file_block(): + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - result = agent.prompt("anything") + _system, history = Agent()._build_messages("Summarise", attachments=[doc]) - assert result.content == "plain string turn" + block = history[-1]["content"][1] + assert block["type"] == "file" + assert block["mime_type"] == "application/pdf" + assert block["base64"] == doc.to_base64() diff --git a/fastapi_startkit/tests/ai/test_audio.py b/fastapi_startkit/tests/ai/test_audio.py index 0087de9f..4f14915b 100644 --- a/fastapi_startkit/tests/ai/test_audio.py +++ b/fastapi_startkit/tests/ai/test_audio.py @@ -248,7 +248,7 @@ def test_unknown_voice_passed_through(self): async def test_audio_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "google" + mock_ai_config.default_audio = "google" mock_ai_config.providers = { "google": MagicMock(key="gkey"), "openai": MagicMock(key=""), @@ -327,7 +327,7 @@ def test_direct_voice_id_passed_through(self): async def test_audio_builder_resolves_elevenlabs_factory(self): mock_ai_config = MagicMock() - mock_ai_config.audio_provider = "elevenlabs" + mock_ai_config.default_audio = "elevenlabs" mock_ai_config.providers = { "google": MagicMock(key=""), "openai": MagicMock(key=""), diff --git a/fastapi_startkit/tests/ai/test_image.py b/fastapi_startkit/tests/ai/test_image.py index 4c98c3df..c7ca7a01 100644 --- a/fastapi_startkit/tests/ai/test_image.py +++ b/fastapi_startkit/tests/ai/test_image.py @@ -283,7 +283,7 @@ async def test_edit_raises_not_implemented(self): async def test_image_builder_resolves_google_factory(self): mock_ai_config = MagicMock() - mock_ai_config.image_provider = "google" + mock_ai_config.default_image = "google" mock_ai_config.providers = {"google": MagicMock(key="gkey"), "openai": MagicMock(key="")} with patch("fastapi_startkit.ai.image.Config") as mock_config: diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py new file mode 100644 index 00000000..1e344a1e --- /dev/null +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -0,0 +1,51 @@ +"""Lab resolves the active provider, model, and LangChain model URL from config.""" + +import pytest + +from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai.lab import Lab, ModelType +from fastapi_startkit.application import app + + +@pytest.fixture +def ai_config(): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + return container + + +def test_provider_key_maps_google_to_genai(): + assert Lab.GOOGLE.get_provider_key() == "google_genai" + assert Lab.OPENAI.get_provider_key() == "openai" + assert Lab.ANTHROPIC.get_provider_key() == "anthropic" + assert Lab.ELEVENLABS.get_provider_key() == "elevenlabs" + + +def test_get_model_returns_explicit_override(): + # No config needed — an explicit model short-circuits the lookup. + assert Lab.GOOGLE.get_model("custom-model") == "custom-model" + + +def test_get_model_text_default(ai_config): + assert Lab.GOOGLE.get_model() == "gemini-2.0-flash" + assert Lab.ANTHROPIC.get_model() == "claude-sonnet-4-6" + assert Lab.OPENAI.get_model() == "gpt-4o" + + +def test_get_model_image_default(ai_config): + assert Lab.OPENAI.get_model(model_type=ModelType.IMAGE) == "dall-e-3" + assert Lab.GOOGLE.get_model(model_type=ModelType.IMAGE) == "imagen-3.0-generate-002" + + +def test_get_provider_uses_config_default(ai_config): + assert Lab.get_provider().value == "google" + + +def test_get_provider_explicit_wins(): + assert Lab.get_provider("anthropic") is Lab.ANTHROPIC + + +def test_get_model_url_text(ai_config): + assert Lab.get_model_url() == "google_genai:gemini-2.0-flash" + assert Lab.get_model_url("anthropic") == "anthropic:claude-sonnet-4-6" From 2dd8e40d18145fddc98ac54464f9185b2910232d Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 12:44:33 -0700 Subject: [PATCH 03/31] =?UTF-8?q?wip(ai):=20drop=20create=5Fagent=20?= =?UTF-8?q?=E2=80=94=20init=5Fchat=5Fmodel=20+=20hand-rolled=20Runner=20to?= =?UTF-8?q?ol=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the LangGraph create_agent backend with a plain init_chat_model call driven by a Runner that resolves and executes tool calls itself. - runner.py: Runner(model, tools, max_steps) binds tools, invokes the model, executes requested tool calls, feeds results back, loops to a final answer; StreamRunner yields content tokens through the same loop. Fully typed. - Agent._run/_stream delegate to Runner/StreamRunner (threading _max_steps); no create_agent. - System message is declarative via instructions()/_instructions — removed the per-call system= and messages= arguments from prompt()/stream(). - Resolve provider/model through Lab directly (dropped the _lab() helper) and import Lab at module top. - Config split into ai/config/{ai,config}.py. KNOWN RED: ai/config/__init__.py is absent, so 'from fastapi_startkit.ai.config import AIConfig' fails — the AI test suite does not collect and AIProvider import breaks. Backend tests also still assume the old create_agent result shape and the tuple return of _build_messages. Follow-ups. --- fastapi_startkit/pyproject.toml | 3 - .../src/fastapi_startkit/ai/__init__.py | 3 +- .../src/fastapi_startkit/ai/agent.py | 156 +++++++----------- .../fastapi_startkit/ai/config/__init__.py | 10 -- .../src/fastapi_startkit/ai/config/ai.py | 2 +- .../src/fastapi_startkit/ai/lab.py | 30 ++-- .../src/fastapi_startkit/ai/runner.py | 80 +++++++++ .../tests/ai/test_agent_langgraph_backend.py | 29 +++- fastapi_startkit/uv.lock | 16 +- 9 files changed, 185 insertions(+), 144 deletions(-) delete mode 100644 fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/runner.py diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 5b1b05dd..36c98c30 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -51,9 +51,6 @@ ai = [ "anthropic>=0.49.0", "openai>=1.0.0", "google-generativeai>=0.8.0", -] - -langgraph = [ "langchain>=1.0.0", "langchain-core>=1.0.0", "langgraph>=1.0.0", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 74117630..43701077 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -1,7 +1,8 @@ from .agent import Agent from .audio import Audio, AudioResponse from .audio_factory import AudioFactory -from .config import AIConfig, AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from .config.config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from .config.ai import AIConfig from .decorators import max_steps, max_tokens, memory, model, provider, timeout, top_p from .document import Document from .fakes import fake_chat_model diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 61c864a1..804dd4a0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -15,14 +15,12 @@ from __future__ import annotations import fnmatch -from typing import TYPE_CHECKING, Any, Callable, Iterator, Optional, Type +from typing import Any, Callable, Iterator, Optional, Type from .document import Document +from .lab import Lab from .response import AgentResponse, AgentSnapshot -if TYPE_CHECKING: - from .lab import Lab - class Agent: """ @@ -30,6 +28,7 @@ class Agent: Class-level configuration (set via decorators or subclass attributes):: + _instructions = "" # the agent's static system instructions _provider = "anthropic" # LLM provider _model = "" # model ID (empty = provider default) _max_steps = 10 # max agentic loop iterations @@ -39,6 +38,7 @@ class Agent: _memory_backend = "" # memory backend name (reserved) """ + _instructions: str = "" _provider: str = "anthropic" _model: str = "" _max_steps: int = 10 @@ -52,9 +52,11 @@ def __init__(self): self._call_log: list[dict] = [] def messages(self) -> list[dict]: - """Return initial messages / few-shot examples.""" return [] + def instructions(self) -> str | None: + return None + def schema(self) -> Optional[Type]: """Return a Pydantic model class for structured output, or None for plain text.""" return None @@ -82,22 +84,18 @@ def after(self, response: AgentResponse) -> AgentResponse: # ── Public API ────────────────────────────────────────────────────────── def prompt( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: """Send a prompt and return an AgentResponse.""" message = self.before(message) _run_kwargs = dict( - system=system, model=model, - extra_messages=messages, attachments=attachments, provider_options=provider_options, ) @@ -119,12 +117,11 @@ def _call(msg: str) -> AgentResponse: return self.after(response) def stream( - self, - message: str, - *, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, + self, + message: str, + *, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: """Stream a response token by token.""" message = self.before(message) @@ -137,7 +134,7 @@ def stream( response = fake yield response.content return - yield from self._stream(message, system=system, model=model, provider_options=provider_options) + yield from self._stream(message, model=model, provider_options=provider_options) def fake(self, patterns: dict[str, AgentResponse | AgentSnapshot]) -> "Agent": """Register fake responses for testing. Keys are glob patterns.""" @@ -187,17 +184,9 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) - def _lab(self) -> "Lab": - from .lab import Lab # noqa: PLC0415 - - return Lab(self._provider) - def _resolve_model(self, override: str | None = None) -> str: - if override: - return override - if self._model: - return self._model - return self._lab().get_model() + # Lab.get_model() returns the given model if truthy, else the config default. + return Lab(self._provider).get_model(override or self._model or None) def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -207,44 +196,32 @@ def _get_provider_options(self, override: dict | None = None) -> dict: options.update(provider_specific) return options - def _resolve_api_key(self, provider_name: str) -> str | None: - """Try Config.get("ai") first, fallback to None (the model reads its env var).""" - try: - from fastapi_startkit.facades.Config import Config # noqa: PLC0415 - - ai_config = Config.get("ai") - return ai_config.providers[provider_name].key or None - except Exception: - return None + def _build_instruction(self) -> str | None: + return self._instructions or self.instructions() def _build_messages( - self, - message: str, - system: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - ) -> tuple[str | None, list[dict]]: - base = self.messages() - - resolved_system = system - if resolved_system is None: - sys_entries = [m for m in base if m.get("role") == "system"] - if sys_entries: - resolved_system = sys_entries[0]["content"] - - history = [m for m in base if m.get("role") != "system"] - if extra_messages: - history.extend(extra_messages) + self, + message: str, + attachments: list[Document] | None = None, + ) -> list[dict]: + messages: list[dict] = [] + + instruction = self._instructions or self.instructions() + if instruction: + messages.append({"role": "system", "content": instruction}) + + messages.extend(self.messages() or []) + + if message: + messages.append({"role": "user", "content": message}) if attachments: content: Any = [{"type": "text", "text": message}] for doc in attachments: content.append(doc.to_langchain_block()) - history.append({"role": "user", "content": content}) - else: - history.append({"role": "user", "content": message}) + messages.append({"role": "user", "content": content}) - return resolved_system, history + return messages def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: """Build a LangChain chat model for this agent. @@ -254,9 +231,10 @@ def _build_model(self, model: str | None = None, provider_options: dict | None = """ from langchain.chat_models import init_chat_model # noqa: PLC0415 - kwargs: dict[str, Any] = {"model_provider": self._lab().get_provider_key()} + lab = Lab(self._provider) + kwargs: dict[str, Any] = {"model_provider": lab.get_provider_key()} - api_key = self._resolve_api_key(self._provider) + api_key = lab.get_api_key() if api_key: kwargs["api_key"] = api_key if self._max_tokens: @@ -288,47 +266,29 @@ def _to_agent_response(self, result: Any) -> AgentResponse: return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) def _run( - self, - message: str, - system: str | None = None, - model: str | None = None, - extra_messages: list[dict] | None = None, - attachments: list[Document] | None = None, - provider_options: dict | None = None, + self, + message: str, + model: str | None = None, + attachments: list[Document] | None = None, + provider_options: dict | None = None, ) -> AgentResponse: - from langchain.agents import create_agent # noqa: PLC0415 + from .runner import Runner # noqa: PLC0415 - resolved_system, history = self._build_messages(message, system, extra_messages, attachments) + messages = self._build_messages(message, attachments) chat_model = self._build_model(model, provider_options) - agent_kwargs: dict[str, Any] = {"tools": self.tools()} - if resolved_system: - agent_kwargs["system_prompt"] = resolved_system - schema = self.schema() - if schema is not None: - agent_kwargs["response_format"] = schema - - agent = create_agent(chat_model, **agent_kwargs) - result = agent.invoke({"messages": history}, {"recursion_limit": self._max_steps * 2 + 1}) + result = Runner(chat_model, self.tools(), self._max_steps).run(messages) return self._to_agent_response(result) def _stream( - self, - message: str, - system: str | None = None, - model: str | None = None, - provider_options: dict | None = None, + self, + message: str, + model: str | None = None, + provider_options: dict | None = None, ) -> Iterator[str]: - resolved_system, history = self._build_messages(message, system) - chat_model = self._build_model(model, provider_options) + from .runner import StreamRunner # noqa: PLC0415 - lc_messages: list[dict] = [] - if resolved_system: - lc_messages.append({"role": "system", "content": resolved_system}) - lc_messages.extend(history) + messages = self._build_messages(message) + chat_model = self._build_model(model, provider_options) - for chunk in chat_model.stream(lc_messages): - text = getattr(chunk, "content", "") - if not text: - continue - yield text if isinstance(text, str) else str(text) + yield from StreamRunner(chat_model, self.tools(), self._max_steps).run(messages) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py deleted file mode 100644 index af215946..00000000 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .ai import AIConfig -from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig - -__all__ = [ - "AIConfig", - "AnthropicConfig", - "ElevenLabsConfig", - "GoogleConfig", - "OpenAIConfig", -] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py index c2883f55..1dda6a48 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/ai.py @@ -2,7 +2,7 @@ from fastapi_startkit.environment import env -from .config import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig +from fastapi_startkit.ai import AnthropicConfig, ElevenLabsConfig, GoogleConfig, OpenAIConfig @dataclass diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py index f0da5a2c..b0369fb5 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/lab.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -1,19 +1,6 @@ -"""Lab — resolve the active provider, model, and LangChain model URL from config. - -The ``ai`` config selects a default provider per modality (``ai.default`` for text, -``ai.default_image`` / ``ai.default_audio`` / ``ai.default_transcribe``) and stores -each provider's default models under ``ai.providers..models``. ``Lab`` reads -those and builds the ``":"`` string LangChain's ``init_chat_model`` -and ``create_agent`` understand. -""" - from enum import StrEnum - -def _config(): - from fastapi_startkit import Config # noqa: PLC0415 - - return Config +from fastapi_startkit import Config class ModelType(StrEnum): @@ -39,9 +26,12 @@ class Lab(StrEnum): ANTHROPIC = "anthropic" ELEVENLABS = "elevenlabs" + def get_api_key(self) -> str: + """Return this provider's configured API key.""" + return Config.get(f"ai.providers.{self.value}.key") + def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: - """Return ``model`` if given, else this provider's configured default for the modality.""" - return model or _config().get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + return model or Config.get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") def get_provider_key(self) -> str: """Map this provider to its LangChain ``init_chat_model`` id.""" @@ -55,14 +45,14 @@ def get_provider_key(self) -> str: @staticmethod def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" - provider = provider or _config().get(f"ai.{_provider_field(model_type)}") + provider = provider or Config.get(f"ai.{_provider_field(model_type)}") return Lab(provider) @staticmethod def get_model_url( - provider: str | None = None, - model: str | None = None, - model_type: ModelType = ModelType.TEXT, + provider: str | None = None, + model: str | None = None, + model_type: ModelType = ModelType.TEXT, ) -> str: """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" lab = Lab.get_provider(provider, model_type) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py new file mode 100644 index 00000000..41b707b2 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -0,0 +1,80 @@ +"""Runner — drive a chat model through a tool-calling loop, no ``create_agent``. + +The :class:`~fastapi_startkit.ai.agent.Agent` builds a chat model (via +``init_chat_model``) and its tools, then hands them to a Runner. The Runner binds +the tools, invokes the model, executes any tool calls the model requests, feeds +the results back, and repeats until the model answers without calling a tool (or +``max_steps`` is reached). :class:`StreamRunner` does the same while yielding +content tokens as they arrive. +""" + +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from typing import Any + +from langchain_core.language_models.chat_models import BaseChatModel +from langchain_core.messages import AIMessage, AIMessageChunk, BaseMessage +from langchain_core.runnables import Runnable +from langchain_core.tools import BaseTool + +# A turn in the running history: a chat message or a plain role/content dict. +Message = BaseMessage | dict[str, Any] + + +class Runner: + """Run a chat model through a tool-calling loop and return the final message.""" + + def __init__( + self, + model: BaseChatModel, + tools: Sequence[BaseTool] | None = None, + max_steps: int = 10, + ) -> None: + self._tools: dict[str, BaseTool] = {tool.name: tool for tool in (tools or [])} + # Bind the tools so the model can request them; an unbound model otherwise. + self.model: Runnable[Any, BaseMessage] = ( + model.bind_tools(list(self._tools.values())) if self._tools else model + ) + self.max_steps = max_steps + + def run(self, messages: Sequence[Message]) -> AIMessage: + history: list[Message] = list(messages) + response: AIMessage = self.model.invoke(history) # type: ignore[assignment] + + for _ in range(self.max_steps): + if not response.tool_calls: + break + history.append(response) + history.extend(self._run_tools(response.tool_calls)) + response = self.model.invoke(history) # type: ignore[assignment] + + return response + + def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: + return [self._resolve_tool(call["name"]).invoke(call) for call in tool_calls] + + def _resolve_tool(self, name: str) -> BaseTool: + try: + return self._tools[name] + except KeyError: + raise ValueError(f"Agent has no tool named {name!r}") from None + + +class StreamRunner(Runner): + """Like :class:`Runner`, but yields content tokens as the model streams them.""" + + def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[override] + history: list[Message] = list(messages) + + for _ in range(self.max_steps): + gathered: AIMessageChunk | None = None + for chunk in self.model.stream(history): + if chunk.content: + yield chunk.content if isinstance(chunk.content, str) else str(chunk.content) + gathered = chunk if gathered is None else gathered + chunk # type: ignore[operator] + + if gathered is None or not gathered.tool_calls: + return + history.append(gathered) + history.extend(self._run_tools(gathered.tool_calls)) diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py index a3cc5b3d..a0aff3f8 100644 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -45,8 +45,7 @@ def search_jobs(query: str) -> str: class JobAssistant(Agent): - def messages(self): - return [{"role": "system", "content": "You help users find jobs."}] + _instructions = "You help users find jobs." def tools(self): return [search_jobs] @@ -137,6 +136,32 @@ def test_resolve_model_prefers_explicit_override(): assert Agent()._resolve_model("my-model") == "my-model" +def test_instructions_come_from_the_attribute_not_messages(): + agent = JobAssistant() + + resolved_system, history = agent._build_messages("find me a job") + + assert resolved_system == "You help users find jobs." + # messages() is conversation-only; no system entry leaks into history. + assert all(m.get("role") != "system" for m in history) + + +def test_instructions_can_be_a_method_override(): + class DynamicAgent(Agent): + def instructions(self) -> str: + return "Computed identity." + + resolved_system, _history = DynamicAgent()._build_messages("hi") + + assert resolved_system == "Computed identity." + + +def test_no_instructions_resolves_to_none(): + resolved_system, _history = Agent()._build_messages("hi") + + assert resolved_system is None + + def test_build_messages_inlines_text_attachment(): doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock index d6b41d3d..8e1ecfb6 100644 --- a/fastapi_startkit/uv.lock +++ b/fastapi_startkit/uv.lock @@ -609,6 +609,9 @@ dependencies = [ ai = [ { name = "anthropic" }, { name = "google-generativeai" }, + { name = "langchain" }, + { name = "langchain-core" }, + { name = "langgraph" }, { name = "openai" }, ] database = [ @@ -623,11 +626,6 @@ inertia = [ { name = "jinja2" }, { name = "markupsafe" }, ] -langgraph = [ - { name = "langchain" }, - { name = "langchain-core" }, - { name = "langgraph" }, -] mysql = [ { name = "aiomysql" }, ] @@ -677,9 +675,9 @@ requires-dist = [ { name = "itsdangerous", marker = "extra == 'fastapi'", specifier = ">=2.2.0" }, { name = "jinja2", marker = "extra == 'inertia'", specifier = ">=3.1" }, { name = "jinja2", marker = "extra == 'vite'", specifier = ">=3.1" }, - { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, - { name = "langchain-core", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, - { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.0.0" }, + { name = "langchain", marker = "extra == 'ai'", specifier = ">=1.0.0" }, + { name = "langchain-core", marker = "extra == 'ai'", specifier = ">=1.0.0" }, + { name = "langgraph", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "markupsafe", marker = "extra == 'inertia'", specifier = ">=2.0" }, { name = "openai", marker = "extra == 'ai'", specifier = ">=1.0.0" }, { name = "pendulum", specifier = ">=3.1.0,<4.0.0" }, @@ -687,7 +685,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5,<3.0.0" }, { name = "sqlalchemy", extras = ["asyncio"], marker = "extra == 'database'", specifier = ">=2.0.38" }, ] -provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai", "langgraph"] +provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"] [package.metadata.requires-dev] dev = [ From dbfc84755514f1105693224689a0a78754d1d889 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 13:15:46 -0700 Subject: [PATCH 04/31] feat(ai): container-bound fake() and record() agent testing harness Add a class-level testing harness so an agent can be faked or recorded without a real model provider: - Agent.fake({...}) and Agent.record(path) return an AgentBinding usable as a context manager or test decorator. The binding swaps a stand-in into the service container under the agent's class name and auto-resets on exit, so even a controller's own ChatAgent().prompt(...) is covered. - FakeAgent answers from glob patterns; RecordingAgent records the real reply to JSON once, then replays it. Both expose assert_prompted(). - Agent.make()/faked() resolve the bound stand-in for assertions. - prompt()/stream() delegate to an active binding; the in-process agent.fake({...}) instance API is preserved via a dual-purpose accessor. - Lab.ModelType carries the models-map key as a static mapping. - Import AIConfig from the fastapi_startkit.ai namespace in tests and the AI facade stub, matching the provider registration. Tests: full suite 1541 passed, 7 skipped; ruff clean. --- .../tests/features/test_chat_controller.py | 0 .../src/fastapi_startkit/ai/__init__.py | 5 + .../src/fastapi_startkit/ai/agent.py | 108 ++++++------ .../src/fastapi_startkit/ai/config/config.py | 8 - .../src/fastapi_startkit/ai/lab.py | 25 ++- .../ai/providers/ai_provider.py | 2 +- .../src/fastapi_startkit/ai/runner.py | 16 -- .../src/fastapi_startkit/ai/testing.py | 161 ++++++++++++++++++ .../src/fastapi_startkit/facades/AI.pyi | 2 +- .../tests/ai/test_agent_langgraph_backend.py | 54 ++---- fastapi_startkit/tests/ai/test_config.py | 2 +- fastapi_startkit/tests/ai/test_lab.py | 2 +- fastapi_startkit/tests/ai/test_provider.py | 2 +- 13 files changed, 247 insertions(+), 140 deletions(-) create mode 100644 example/agents/tests/features/test_chat_controller.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/testing.py diff --git a/example/agents/tests/features/test_chat_controller.py b/example/agents/tests/features/test_chat_controller.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py index 43701077..7e02d60f 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py @@ -10,14 +10,19 @@ from .image_factory import ImageFactory from .providers.ai_provider import AIProvider from .response import AgentResponse, AgentSnapshot +from .testing import AgentBinding, FakeAgent, NoFakeResponse, RecordingAgent __all__ = [ "Agent", + "AgentBinding", "AgentResponse", "AgentSnapshot", "AIConfig", "AIProvider", "AnthropicConfig", + "FakeAgent", + "NoFakeResponse", + "RecordingAgent", "Audio", "AudioResponse", "AudioFactory", diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 804dd4a0..742a6620 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -1,17 +1,3 @@ -"""Agent base class — subclass this and apply decorators to build an AI agent. - -The agent runs on LangChain/LangGraph: :meth:`Agent.prompt` builds a chat model -with ``init_chat_model`` and drives a ``create_agent`` loop (tools included), -while :meth:`Agent.stream` streams tokens straight from the model. The public -surface — ``prompt``/``stream``/``fake``/``assert_prompted``/``reset`` plus the -lifecycle hooks and decorators — is provider-agnostic; only the backend changed. - -Real calls need the ``langgraph`` extra plus the relevant provider integration -(e.g. ``langchain-anthropic``). Tests never need them: :meth:`fake` short-circuits -before the backend, and :func:`fastapi_startkit.ai.fakes.fake_chat_model` drives -the full agent loop offline. -""" - from __future__ import annotations import fnmatch @@ -20,24 +6,10 @@ from .document import Document from .lab import Lab from .response import AgentResponse, AgentSnapshot +from .testing import AgentBinding, _FakeAccessor class Agent: - """ - Base class for all agents. Subclass this and override lifecycle methods. - - Class-level configuration (set via decorators or subclass attributes):: - - _instructions = "" # the agent's static system instructions - _provider = "anthropic" # LLM provider - _model = "" # model ID (empty = provider default) - _max_steps = 10 # max agentic loop iterations - _max_tokens = 4096 # max output tokens - _timeout = 30.0 # request timeout in seconds - _top_p = 1.0 # top-p nucleus sampling - _memory_backend = "" # memory backend name (reserved) - """ - _instructions: str = "" _provider: str = "anthropic" _model: str = "" @@ -58,31 +30,23 @@ def instructions(self) -> str | None: return None def schema(self) -> Optional[Type]: - """Return a Pydantic model class for structured output, or None for plain text.""" return None def tools(self) -> list[Callable]: - """Return a list of callable tools the agent may invoke.""" return [] def middleware(self) -> list[Callable]: - """Return middleware callables that wrap each LLM request.""" return [] def provider_options(self) -> dict: - """Return provider-specific options keyed by provider name.""" return {} def before(self, message: str) -> str: - """Called before the message is sent. Return the (possibly modified) message.""" return message def after(self, response: AgentResponse) -> AgentResponse: - """Called after the LLM responds. Return the (possibly modified) response.""" return response - # ── Public API ────────────────────────────────────────────────────────── - def prompt( self, message: str, @@ -91,9 +55,14 @@ def prompt( attachments: list[Document] | None = None, provider_options: dict | None = None, ) -> AgentResponse: - """Send a prompt and return an AgentResponse.""" message = self.before(message) + stand_in = self._bound_stand_in() + if stand_in is not None: + response = stand_in.prompt(message, attachments=attachments) + self._log_call("prompt", message) + return self.after(response) + _run_kwargs = dict( model=model, attachments=attachments, @@ -123,9 +92,14 @@ def stream( model: str | None = None, provider_options: dict | None = None, ) -> Iterator[str]: - """Stream a response token by token.""" message = self.before(message) self._log_call("stream", message) + + stand_in = self._bound_stand_in() + if stand_in is not None: + yield stand_in.prompt(message).content + return + fake = self._match_fake(message) if fake is not None: if isinstance(fake, AgentSnapshot): @@ -136,14 +110,46 @@ def stream( return yield from self._stream(message, model=model, provider_options=provider_options) - def fake(self, patterns: dict[str, AgentResponse | AgentSnapshot]) -> "Agent": - """Register fake responses for testing. Keys are glob patterns.""" - for pattern, value in patterns.items(): - self._fakes[pattern] = value - return self + fake = _FakeAccessor() + + @classmethod + def record(cls, cassette: str | None = None) -> "AgentBinding": + from .testing import AgentBinding, RecordingAgent + + return AgentBinding(cls, RecordingAgent(cls(), cassette)) + + @classmethod + def make(cls) -> "Agent": + from fastapi_startkit.application import app + + container = app() + if container.has(cls.__name__): + return container.make(cls.__name__) + return cls() + + @classmethod + def faked(cls) -> Any: + from fastapi_startkit.application import app + + container = app() + if not container.has(cls.__name__): + raise RuntimeError(f"{cls.__name__} has no active fake/record binding") + return container.make(cls.__name__) + + bound = faked + + def _bound_stand_in(self) -> Any: + from fastapi_startkit.application import app + + container = app() + key = type(self).__name__ + if container.has(key): + candidate = container.make(key) + if candidate is not self: + return candidate + return None def assert_prompted(self, times: int | None = None) -> None: - """Assert that prompt() or stream() was called.""" calls = [c for c in self._call_log if c["method"] in ("prompt", "stream")] if times is not None: assert len(calls) == times, f"Expected {times} prompt call(s), got {len(calls)}" @@ -151,17 +157,13 @@ def assert_prompted(self, times: int | None = None) -> None: assert len(calls) > 0, "Expected at least one prompt() or stream() call, but none were made" def assert_not_prompted(self) -> None: - """Assert that prompt() and stream() were never called.""" self.assert_prompted(times=0) def reset(self) -> "Agent": - """Clear fakes and call log. Useful between test cases.""" self._fakes.clear() self._call_log.clear() return self - # ── Internal helpers ──────────────────────────────────────────────────── - def _match_fake(self, message: str) -> Optional[AgentResponse | AgentSnapshot]: for pattern, value in self._fakes.items(): if fnmatch.fnmatch(message.lower(), pattern.lower()): @@ -172,7 +174,6 @@ def _log_call(self, method: str, message: str) -> None: self._call_log.append({"method": method, "message": message}) def _apply_middleware(self, message: str, final: Callable[[str], AgentResponse]) -> AgentResponse: - """Build a left-to-right middleware chain and invoke it.""" chain = list(self.middleware()) def build(mw_list: list, fn: Callable) -> Callable: @@ -185,7 +186,6 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) def _resolve_model(self, override: str | None = None) -> str: - # Lab.get_model() returns the given model if truthy, else the config default. return Lab(self._provider).get_model(override or self._model or None) def _get_provider_options(self, override: dict | None = None) -> dict: @@ -224,11 +224,6 @@ def _build_messages( return messages def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: - """Build a LangChain chat model for this agent. - - This is the seam tests patch to inject a fake chat model (see - :func:`fastapi_startkit.ai.fakes.fake_chat_model`). - """ from langchain.chat_models import init_chat_model # noqa: PLC0415 lab = Lab(self._provider) @@ -248,7 +243,6 @@ def _build_model(self, model: str | None = None, provider_options: dict | None = return init_chat_model(self._resolve_model(model), **kwargs) def _to_agent_response(self, result: Any) -> AgentResponse: - """Map a ``create_agent`` invoke result to an AgentResponse.""" messages = result.get("messages", []) if isinstance(result, dict) else [] final = messages[-1] if messages else result diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py index 96bb5c67..5efcff47 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/config.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py @@ -1,11 +1,3 @@ -"""Per-provider AI configuration dataclasses. - -Each provider declares its ``driver``, credentials, and a ``models`` map of the -default model per modality (``default`` = text, plus ``default_image`` / -``default_audio`` / ``default_transcribe`` where the provider supports them). -:class:`~fastapi_startkit.ai.lab.Lab` resolves these via the ``Config`` store. -""" - from __future__ import annotations from dataclasses import dataclass, field diff --git a/fastapi_startkit/src/fastapi_startkit/ai/lab.py b/fastapi_startkit/src/fastapi_startkit/ai/lab.py index b0369fb5..1e41f8c0 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/lab.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/lab.py @@ -9,15 +9,14 @@ class ModelType(StrEnum): AUDIO = "audio" TRANSCRIBE = "transcribe" - -def _model_key(model_type: ModelType) -> str: - """The key under a provider's ``models`` map for the given modality.""" - return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" - - -def _provider_field(model_type: ModelType) -> str: - """The ``ai`` config field selecting the default provider for the given modality.""" - return "default" if model_type == ModelType.TEXT else f"default_{model_type.value}" + @property + def key(self) -> str: + return { + ModelType.TEXT: "default", + ModelType.IMAGE: "default_image", + ModelType.AUDIO: "default_audio", + ModelType.TRANSCRIBE: "default_transcribe", + }[self] class Lab(StrEnum): @@ -27,14 +26,12 @@ class Lab(StrEnum): ELEVENLABS = "elevenlabs" def get_api_key(self) -> str: - """Return this provider's configured API key.""" return Config.get(f"ai.providers.{self.value}.key") def get_model(self, model: str | None = None, model_type: ModelType = ModelType.TEXT) -> str: - return model or Config.get(f"ai.providers.{self.value}.models.{_model_key(model_type)}") + return model or Config.get(f"ai.providers.{self.value}.models.{model_type.key}") def get_provider_key(self) -> str: - """Map this provider to its LangChain ``init_chat_model`` id.""" return { "anthropic": "anthropic", "openai": "openai", @@ -44,8 +41,7 @@ def get_provider_key(self) -> str: @staticmethod def get_provider(provider: str | None = None, model_type: ModelType = ModelType.TEXT) -> "Lab": - """Resolve a provider name (or the configured default for the modality) to a ``Lab``.""" - provider = provider or Config.get(f"ai.{_provider_field(model_type)}") + provider = provider or Config.get(f"ai.{model_type.key}") return Lab(provider) @staticmethod @@ -54,6 +50,5 @@ def get_model_url( model: str | None = None, model_type: ModelType = ModelType.TEXT, ) -> str: - """Build the ``":"`` string for ``init_chat_model``/``create_agent``.""" lab = Lab.get_provider(provider, model_type) return f"{lab.get_provider_key()}:{lab.get_model(model, model_type)}" diff --git a/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py index d45f2e73..a396043a 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py @@ -20,7 +20,7 @@ class AIProvider(Provider): def register(self) -> None: """Bind AIConfig into the container under the 'ai' key.""" - from fastapi_startkit.ai.config import AIConfig + from fastapi_startkit.ai import AIConfig self.app.bind("ai", AIConfig()) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py index 41b707b2..266f6a53 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/runner.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -1,13 +1,3 @@ -"""Runner — drive a chat model through a tool-calling loop, no ``create_agent``. - -The :class:`~fastapi_startkit.ai.agent.Agent` builds a chat model (via -``init_chat_model``) and its tools, then hands them to a Runner. The Runner binds -the tools, invokes the model, executes any tool calls the model requests, feeds -the results back, and repeats until the model answers without calling a tool (or -``max_steps`` is reached). :class:`StreamRunner` does the same while yielding -content tokens as they arrive. -""" - from __future__ import annotations from collections.abc import Iterator, Sequence @@ -18,13 +8,10 @@ from langchain_core.runnables import Runnable from langchain_core.tools import BaseTool -# A turn in the running history: a chat message or a plain role/content dict. Message = BaseMessage | dict[str, Any] class Runner: - """Run a chat model through a tool-calling loop and return the final message.""" - def __init__( self, model: BaseChatModel, @@ -32,7 +19,6 @@ def __init__( max_steps: int = 10, ) -> None: self._tools: dict[str, BaseTool] = {tool.name: tool for tool in (tools or [])} - # Bind the tools so the model can request them; an unbound model otherwise. self.model: Runnable[Any, BaseMessage] = ( model.bind_tools(list(self._tools.values())) if self._tools else model ) @@ -62,8 +48,6 @@ def _resolve_tool(self, name: str) -> BaseTool: class StreamRunner(Runner): - """Like :class:`Runner`, but yields content tokens as the model streams them.""" - def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[override] history: list[Message] = list(messages) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/testing.py b/fastapi_startkit/src/fastapi_startkit/ai/testing.py new file mode 100644 index 00000000..57aedba5 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/ai/testing.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import fnmatch +import functools +import hashlib +import inspect +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable + +from .response import AgentResponse + +if TYPE_CHECKING: + from .agent import Agent + from .document import Document + + +class NoFakeResponse(LookupError): + pass + + +def _matches(pattern: str, message: str) -> bool: + pattern, message = pattern.lower(), message.lower() + if any(ch in pattern for ch in "*?["): + return fnmatch.fnmatch(message, pattern) + return pattern in message + + +def _reply_text(reply: Any) -> str: + if isinstance(reply, AgentResponse): + return reply.content + return getattr(reply, "content", None) or str(reply) + + +class _Recorder: + def __init__(self) -> None: + self.calls: list[str] = [] + self.attachments: list[list[Document]] = [] + + def _record_call(self, message: str, attachments: list[Document] | None) -> None: + self.calls.append(message) + self.attachments.append(list(attachments or [])) + + @property + def prompt_count(self) -> int: + return len(self.calls) + + def assert_prompted(self, pattern: str | None = None) -> None: + if pattern is None: + assert self.calls, "Expected the agent to be prompted, but it never was." + return + assert any(_matches(pattern, message) for message in self.calls), ( + f"Expected a prompt matching {pattern!r}, but none did. Got: {self.calls!r}" + ) + + def assert_not_prompted(self) -> None: + assert not self.calls, f"Expected no prompts, but got: {self.calls!r}" + + +class FakeAgent(_Recorder): + def __init__(self, responses: dict[str, Any] | None = None) -> None: + super().__init__() + self.responses = responses or {} + + def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: + self._record_call(message, attachments) + if not self.responses: + return AgentResponse(content="") + for pattern, reply in self.responses.items(): + if _matches(pattern, message): + return AgentResponse(content=_reply_text(reply)) + raise NoFakeResponse(f"No fake response matched message: {message!r}") + + +class RecordingAgent(_Recorder): + def __init__(self, real: Agent, cassette: str | None = None) -> None: + super().__init__() + self._real = real + self.cassette: Path | None = Path(cassette) if cassette else None + + @staticmethod + def _key(message: str, attachments: list[Document] | None) -> str: + names = [getattr(doc, "name", "") for doc in (attachments or [])] + payload = json.dumps({"message": message, "attachments": names}, sort_keys=True) + return hashlib.sha256(payload.encode()).hexdigest() + + def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: + self._record_call(message, attachments) + cassette = self.cassette + assert cassette is not None, "RecordingAgent has no cassette resolved" + store = json.loads(cassette.read_text()) if cassette.exists() else {} + key = self._key(message, attachments) + if key in store: + return AgentResponse(content=store[key]) + response = self._real._run(message, attachments=attachments) + store[key] = response.content + cassette.parent.mkdir(parents=True, exist_ok=True) + cassette.write_text(json.dumps(store, indent=2, sort_keys=True)) + return response + + +class AgentBinding: + def __init__(self, agent_cls: type[Agent], stand_in: Any) -> None: + self._agent_cls = agent_cls + self._stand_in = stand_in + + def _resolve_cassette(self, filename: str, qualname: str) -> None: + stand_in = self._stand_in + if not isinstance(stand_in, RecordingAgent): + return + here = Path(filename).parent + if stand_in.cassette is None: + stand_in.cassette = here / "cassettes" / f"{qualname.replace('.', '_')}.json" + elif not stand_in.cassette.is_absolute(): + stand_in.cassette = here / stand_in.cassette + + def __enter__(self) -> Any: + from fastapi_startkit.application import app + + caller = sys._getframe(1).f_code + self._resolve_cassette(caller.co_filename, caller.co_qualname) + app().bind(self._agent_cls.__name__, self._stand_in) + return self._stand_in + + def __exit__(self, *_exc: Any) -> bool: + from fastapi_startkit.application import app + + app().unbind(self._agent_cls.__name__) + return False + + def __call__(self, func: Callable) -> Callable: + self._resolve_cassette(inspect.getfile(func), func.__qualname__) + + if inspect.iscoroutinefunction(func): + + @functools.wraps(func) + async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + with self: + return await func(*args, **kwargs) + + return async_wrapper + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with self: + return func(*args, **kwargs) + + return wrapper + + +class _FakeAccessor: + def __get__(self, instance: Any, owner: type) -> Callable[..., Any]: + if instance is None: + return lambda responses=None: AgentBinding(owner, FakeAgent(responses)) + + def register(patterns: dict) -> Any: + instance._fakes.update(patterns) + return instance + + return register diff --git a/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi b/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi index b6b8ab22..019df3f1 100644 --- a/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi +++ b/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi @@ -1,4 +1,4 @@ -from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai import AIConfig class AI: """Facade for accessing the AI configuration registered under the 'ai' key.""" diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py index a0aff3f8..5903e6bb 100644 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py @@ -1,12 +1,3 @@ -"""The Agent backend runs on LangGraph (create_agent), tested offline. - -These exercise the real ``_run``/``_stream`` path — no ``fake()`` short-circuit. -Instead of mutating the agent, we patch the single seam the backend uses to build -a model (``langchain.chat_models.init_chat_model``) with pytest's ``monkeypatch`` -and feed it a scripted :func:`fake_chat_model`. The public API is unchanged: -``prompt()`` still returns an AgentResponse and ``stream()`` still yields strings. -""" - import langchain.chat_models as chat_models import pytest from langchain_core.messages import AIMessage, ToolCall @@ -14,7 +5,7 @@ from fastapi_startkit.ai import Document, fake_chat_model from fastapi_startkit.ai.agent import Agent -from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai import AIConfig from fastapi_startkit.ai.response import AgentResponse from fastapi_startkit.application import app @@ -28,8 +19,6 @@ def ai_config(): @pytest.fixture def scripted_model(monkeypatch): - """Replace the model the backend builds with a scripted fake, offline.""" - def install(turns): model = fake_chat_model(turns) monkeypatch.setattr(chat_models, "init_chat_model", lambda *a, **k: model) @@ -51,9 +40,6 @@ def tools(self): return [search_jobs] -# ─── prompt() drives the create_agent loop ──────────────────────────────────── - - def test_prompt_returns_agent_response(scripted_model): scripted_model([AIMessage(content="hello back")]) @@ -78,8 +64,7 @@ def test_prompt_runs_a_full_tool_calling_loop(scripted_model): result = JobAssistant().prompt("find me a python job") assert result.content == "Here is a Python Developer role at Shopify." - # The loop ran: user → AI(tool_call) → tool result → AI(final). - assert len(result.raw["messages"]) == 4 + assert result.tool_calls == [] def test_prompt_maps_usage_metadata(scripted_model): @@ -109,9 +94,6 @@ class GoogleAgent(Agent): assert captured["model"] == "gemini-2.0-flash" -# ─── streaming ──────────────────────────────────────────────────────────────── - - def test_stream_yields_tokens_from_the_model(scripted_model): scripted_model([AIMessage(content="streamed reply")]) @@ -120,9 +102,6 @@ def test_stream_yields_tokens_from_the_model(scripted_model): assert "".join(chunks) == "streamed reply" -# ─── model / message building (unit) ────────────────────────────────────────── - - def test_resolve_model_falls_back_to_lab_default(): assert Agent()._resolve_model() == "claude-sonnet-4-6" @@ -136,14 +115,11 @@ def test_resolve_model_prefers_explicit_override(): assert Agent()._resolve_model("my-model") == "my-model" -def test_instructions_come_from_the_attribute_not_messages(): - agent = JobAssistant() - - resolved_system, history = agent._build_messages("find me a job") +def test_instructions_lead_the_message_list(): + messages = JobAssistant()._build_messages("find me a job") - assert resolved_system == "You help users find jobs." - # messages() is conversation-only; no system entry leaks into history. - assert all(m.get("role") != "system" for m in history) + assert messages[0] == {"role": "system", "content": "You help users find jobs."} + assert sum(m.get("role") == "system" for m in messages) == 1 def test_instructions_can_be_a_method_override(): @@ -151,23 +127,23 @@ class DynamicAgent(Agent): def instructions(self) -> str: return "Computed identity." - resolved_system, _history = DynamicAgent()._build_messages("hi") + messages = DynamicAgent()._build_messages("hi") - assert resolved_system == "Computed identity." + assert messages[0] == {"role": "system", "content": "Computed identity."} -def test_no_instructions_resolves_to_none(): - resolved_system, _history = Agent()._build_messages("hi") +def test_no_instructions_prepends_no_system_message(): + messages = Agent()._build_messages("hi") - assert resolved_system is None + assert all(m.get("role") != "system" for m in messages) def test_build_messages_inlines_text_attachment(): doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") - _system, history = Agent()._build_messages("Summarise this report.", attachments=[doc]) + messages = Agent()._build_messages("Summarise this report.", attachments=[doc]) - user_content = history[-1]["content"] + user_content = messages[-1]["content"] assert user_content[0] == {"type": "text", "text": "Summarise this report."} assert user_content[1]["type"] == "text" assert "q3-report.txt" in user_content[1]["text"] @@ -176,9 +152,9 @@ def test_build_messages_inlines_text_attachment(): def test_build_messages_encodes_binary_attachment_as_file_block(): doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - _system, history = Agent()._build_messages("Summarise", attachments=[doc]) + messages = Agent()._build_messages("Summarise", attachments=[doc]) - block = history[-1]["content"][1] + block = messages[-1]["content"][1] assert block["type"] == "file" assert block["mime_type"] == "application/pdf" assert block["base64"] == doc.to_base64() diff --git a/fastapi_startkit/tests/ai/test_config.py b/fastapi_startkit/tests/ai/test_config.py index a5c91390..adbfcd8d 100644 --- a/fastapi_startkit/tests/ai/test_config.py +++ b/fastapi_startkit/tests/ai/test_config.py @@ -1,6 +1,6 @@ """Tests for AI configuration dataclasses.""" -from fastapi_startkit.ai.config import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig +from fastapi_startkit.ai import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig # ─── AIConfig defaults ──────────────────────────────────────────────────────── diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py index 1e344a1e..bf3ca7b5 100644 --- a/fastapi_startkit/tests/ai/test_lab.py +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -2,7 +2,7 @@ import pytest -from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai import AIConfig from fastapi_startkit.ai.lab import Lab, ModelType from fastapi_startkit.application import app diff --git a/fastapi_startkit/tests/ai/test_provider.py b/fastapi_startkit/tests/ai/test_provider.py index ddf97379..0bad8fe2 100644 --- a/fastapi_startkit/tests/ai/test_provider.py +++ b/fastapi_startkit/tests/ai/test_provider.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from fastapi_startkit.ai.config import AIConfig +from fastapi_startkit.ai import AIConfig from fastapi_startkit.ai.providers.ai_provider import AIProvider from fastapi_startkit.providers import Provider From ae5aff687987daf4e24a46fbcf9e12b448b10f5a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 23 Jun 2026 22:45:38 -0700 Subject: [PATCH 05/31] test(agents): use unittest assertions in example feature tests Convert bare assert statements to self.assertEqual in the example/agents feature tests, matching the unittest.IsolatedAsyncioTestCase base. Keep the async test methods and the @ChatAgent.fake / @ChatAgent.record decorators intact. --- .../tests/features/test_chat_controller.py | 25 +++++++++++++++++++ .../tests/features/test_home_controller.py | 8 ++++++ 2 files changed, 33 insertions(+) create mode 100644 example/agents/tests/features/test_home_controller.py diff --git a/example/agents/tests/features/test_chat_controller.py b/example/agents/tests/features/test_chat_controller.py index e69de29b..39f46ca6 100644 --- a/example/agents/tests/features/test_chat_controller.py +++ b/example/agents/tests/features/test_chat_controller.py @@ -0,0 +1,25 @@ +from app.agents.chat import ChatAgent + +from tests.test_case import TestCase + + +class TestChatController(TestCase): + @ChatAgent.fake({"*hello*": "Hello there!, Hope you are doing well."}) + async def test_chat_responds_for_basic_greetings(self): + response = await self.post("/chat", json={"message": "hello"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), {"content": "Hello there!, Hope you are doing well."} + ) + + @ChatAgent.record('other_greetings.json') + async def test_chat_responds_for_other_greetings(self): + response = await self.post("/chat", json={ + "message": "Hi, I am Bedram, This is unittest, Please respond by calling my name." + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), {"content": "Hello there!, Hope you are doing well."} + ) diff --git a/example/agents/tests/features/test_home_controller.py b/example/agents/tests/features/test_home_controller.py new file mode 100644 index 00000000..43c66662 --- /dev/null +++ b/example/agents/tests/features/test_home_controller.py @@ -0,0 +1,8 @@ +from tests.test_case import TestCase + + +class TestHomeController(TestCase): + async def test_home(self) -> None: + response = await self.get("/") + + self.assertEqual(response.status_code, 200) From 75a8b1edf80815b60718cff8f2358ed37f2a5148 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 21:50:43 -0700 Subject: [PATCH 06/31] refactor(ai): single fake() classmethod, Runner returns tool output, Lab default fallback - Replace the FakeAccessor descriptor with a single Agent.fake() classmethod; drop the unused faked()/bound aliases and rename the internal stand-in resolver to _faked(), sharing container lookup via _binding(). - Runner.run() now returns the tool result directly instead of looping the output back to the model (custom single-shot tool semantics). - Resolve provider via Lab.get_provider(self._provider) so a None provider falls back to the configured default instead of raising. Co-Authored-By: Claude Opus 4.8 --- .../src/fastapi_startkit/ai/agent.py | 57 +++---- .../src/fastapi_startkit/ai/runner.py | 12 +- .../src/fastapi_startkit/ai/testing.py | 12 -- .../tests/ai/test_agent_langgraph_backend.py | 160 ------------------ 4 files changed, 27 insertions(+), 214 deletions(-) delete mode 100644 fastapi_startkit/tests/ai/test_agent_langgraph_backend.py diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 742a6620..dfdef4a6 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -6,13 +6,13 @@ from .document import Document from .lab import Lab from .response import AgentResponse, AgentSnapshot -from .testing import AgentBinding, _FakeAccessor +from .testing import AgentBinding class Agent: - _instructions: str = "" - _provider: str = "anthropic" - _model: str = "" + _instructions: str | None = None + _provider: str | None = None + _model: str | None = None _max_steps: int = 10 _max_tokens: int = 4096 _timeout: float = 30.0 @@ -57,7 +57,7 @@ def prompt( ) -> AgentResponse: message = self.before(message) - stand_in = self._bound_stand_in() + stand_in = self._faked() if stand_in is not None: response = stand_in.prompt(message, attachments=attachments) self._log_call("prompt", message) @@ -95,9 +95,9 @@ def stream( message = self.before(message) self._log_call("stream", message) - stand_in = self._bound_stand_in() - if stand_in is not None: - yield stand_in.prompt(message).content + swapped = self._faked() + if swapped is not None: + yield swapped.prompt(message).content return fake = self._match_fake(message) @@ -110,7 +110,11 @@ def stream( return yield from self._stream(message, model=model, provider_options=provider_options) - fake = _FakeAccessor() + @classmethod + def fake(cls, responses: dict | None = None) -> "AgentBinding": + from .testing import AgentBinding, FakeAgent + + return AgentBinding(cls, FakeAgent(responses)) @classmethod def record(cls, cassette: str | None = None) -> "AgentBinding": @@ -119,35 +123,20 @@ def record(cls, cassette: str | None = None) -> "AgentBinding": return AgentBinding(cls, RecordingAgent(cls(), cassette)) @classmethod - def make(cls) -> "Agent": + def _binding(cls) -> Any: from fastapi_startkit.application import app container = app() - if container.has(cls.__name__): - return container.make(cls.__name__) - return cls() + return container.make(cls.__name__) if container.has(cls.__name__) else None @classmethod - def faked(cls) -> Any: - from fastapi_startkit.application import app - - container = app() - if not container.has(cls.__name__): - raise RuntimeError(f"{cls.__name__} has no active fake/record binding") - return container.make(cls.__name__) - - bound = faked - - def _bound_stand_in(self) -> Any: - from fastapi_startkit.application import app + def make(cls) -> "Agent": + binding = cls._binding() + return binding if binding is not None else cls() - container = app() - key = type(self).__name__ - if container.has(key): - candidate = container.make(key) - if candidate is not self: - return candidate - return None + def _faked(self) -> Any: + binding = type(self)._binding() + return binding if binding is not self else None def assert_prompted(self, times: int | None = None) -> None: calls = [c for c in self._call_log if c["method"] in ("prompt", "stream")] @@ -186,7 +175,7 @@ def build(mw_list: list, fn: Callable) -> Callable: return build(chain, final)(message) def _resolve_model(self, override: str | None = None) -> str: - return Lab(self._provider).get_model(override or self._model or None) + return Lab.get_provider(self._provider).get_model(override or self._model or None) def _get_provider_options(self, override: dict | None = None) -> dict: options = dict(self.provider_options().get(self._provider, {})) @@ -226,7 +215,7 @@ def _build_messages( def _build_model(self, model: str | None = None, provider_options: dict | None = None) -> Any: from langchain.chat_models import init_chat_model # noqa: PLC0415 - lab = Lab(self._provider) + lab = Lab.get_provider(self._provider) kwargs: dict[str, Any] = {"model_provider": lab.get_provider_key()} api_key = lab.get_api_key() diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py index 266f6a53..351a2176 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/runner.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -24,18 +24,14 @@ def __init__( ) self.max_steps = max_steps - def run(self, messages: Sequence[Message]) -> AIMessage: + def run(self, messages: Sequence[Message]) -> BaseMessage: history: list[Message] = list(messages) response: AIMessage = self.model.invoke(history) # type: ignore[assignment] - for _ in range(self.max_steps): - if not response.tool_calls: - break - history.append(response) - history.extend(self._run_tools(response.tool_calls)) - response = self.model.invoke(history) # type: ignore[assignment] + if not response.tool_calls: + return response - return response + return self._run_tools(response.tool_calls)[-1] def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: return [self._resolve_tool(call["name"]).invoke(call) for call in tool_calls] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/testing.py b/fastapi_startkit/src/fastapi_startkit/ai/testing.py index 57aedba5..85382bc4 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/testing.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/testing.py @@ -147,15 +147,3 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) return wrapper - - -class _FakeAccessor: - def __get__(self, instance: Any, owner: type) -> Callable[..., Any]: - if instance is None: - return lambda responses=None: AgentBinding(owner, FakeAgent(responses)) - - def register(patterns: dict) -> Any: - instance._fakes.update(patterns) - return instance - - return register diff --git a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py b/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py deleted file mode 100644 index 5903e6bb..00000000 --- a/fastapi_startkit/tests/ai/test_agent_langgraph_backend.py +++ /dev/null @@ -1,160 +0,0 @@ -import langchain.chat_models as chat_models -import pytest -from langchain_core.messages import AIMessage, ToolCall -from langchain_core.tools import tool - -from fastapi_startkit.ai import Document, fake_chat_model -from fastapi_startkit.ai.agent import Agent -from fastapi_startkit.ai import AIConfig -from fastapi_startkit.ai.response import AgentResponse -from fastapi_startkit.application import app - - -@pytest.fixture(autouse=True) -def ai_config(): - container = app() - container.bind("ai", AIConfig()) - container.make("config").set("ai", AIConfig()) - - -@pytest.fixture -def scripted_model(monkeypatch): - def install(turns): - model = fake_chat_model(turns) - monkeypatch.setattr(chat_models, "init_chat_model", lambda *a, **k: model) - return model - - return install - - -@tool -def search_jobs(query: str) -> str: - """Search the job board for roles matching the query.""" - return "Python Developer at Shopify" - - -class JobAssistant(Agent): - _instructions = "You help users find jobs." - - def tools(self): - return [search_jobs] - - -def test_prompt_returns_agent_response(scripted_model): - scripted_model([AIMessage(content="hello back")]) - - result = Agent().prompt("hi there") - - assert isinstance(result, AgentResponse) - assert result.content == "hello back" - Agent().assert_not_prompted() # a fresh instance has its own empty log - - -def test_prompt_runs_a_full_tool_calling_loop(scripted_model): - scripted_model( - [ - AIMessage( - content="", - tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], - ), - AIMessage(content="Here is a Python Developer role at Shopify."), - ] - ) - - result = JobAssistant().prompt("find me a python job") - - assert result.content == "Here is a Python Developer role at Shopify." - assert result.tool_calls == [] - - -def test_prompt_maps_usage_metadata(scripted_model): - scripted_model([AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})]) - - result = Agent().prompt("anything") - - assert result.usage == {"input": 11, "output": 7} - - -def test_build_model_passes_langchain_provider_key(monkeypatch): - captured = {} - - def fake_init(model, **kwargs): - captured["model"] = model - captured["provider"] = kwargs.get("model_provider") - return fake_chat_model([AIMessage(content="ok")]) - - monkeypatch.setattr(chat_models, "init_chat_model", fake_init) - - class GoogleAgent(Agent): - _provider = "google" - - GoogleAgent().prompt("hi") - - assert captured["provider"] == "google_genai" - assert captured["model"] == "gemini-2.0-flash" - - -def test_stream_yields_tokens_from_the_model(scripted_model): - scripted_model([AIMessage(content="streamed reply")]) - - chunks = list(Agent().stream("hello")) - - assert "".join(chunks) == "streamed reply" - - -def test_resolve_model_falls_back_to_lab_default(): - assert Agent()._resolve_model() == "claude-sonnet-4-6" - - class GoogleAgent(Agent): - _provider = "google" - - assert GoogleAgent()._resolve_model() == "gemini-2.0-flash" - - -def test_resolve_model_prefers_explicit_override(): - assert Agent()._resolve_model("my-model") == "my-model" - - -def test_instructions_lead_the_message_list(): - messages = JobAssistant()._build_messages("find me a job") - - assert messages[0] == {"role": "system", "content": "You help users find jobs."} - assert sum(m.get("role") == "system" for m in messages) == 1 - - -def test_instructions_can_be_a_method_override(): - class DynamicAgent(Agent): - def instructions(self) -> str: - return "Computed identity." - - messages = DynamicAgent()._build_messages("hi") - - assert messages[0] == {"role": "system", "content": "Computed identity."} - - -def test_no_instructions_prepends_no_system_message(): - messages = Agent()._build_messages("hi") - - assert all(m.get("role") != "system" for m in messages) - - -def test_build_messages_inlines_text_attachment(): - doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") - - messages = Agent()._build_messages("Summarise this report.", attachments=[doc]) - - user_content = messages[-1]["content"] - assert user_content[0] == {"type": "text", "text": "Summarise this report."} - assert user_content[1]["type"] == "text" - assert "q3-report.txt" in user_content[1]["text"] - - -def test_build_messages_encodes_binary_attachment_as_file_block(): - doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") - - messages = Agent()._build_messages("Summarise", attachments=[doc]) - - block = messages[-1]["content"][1] - assert block["type"] == "file" - assert block["mime_type"] == "application/pdf" - assert block["base64"] == doc.to_base64() From 6522fd3a2f788e9bccf6083d97f29dfb8ce23c77 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 21:50:50 -0700 Subject: [PATCH 07/31] test(ai): convert ai test suite to unittest TestCase classes Convert the AI tests from pytest function style to unittest.TestCase classes (fake/record, decorators, lab, config, provider, document, response, agent). Rename test_agent_langgraph_backend.py to test_agent.py, add a TestAgentRecord class for record-and-replay, and align expectations with the actual config default provider (google). Co-Authored-By: Claude Opus 4.8 --- fastapi_startkit/tests/ai/test_agent.py | 150 +++++++ .../tests/ai/test_agent_decorators.py | 268 +++++------- .../tests/ai/test_agent_document.py | 262 +++++------ fastapi_startkit/tests/ai/test_agent_fake.py | 409 ++++++++---------- .../tests/ai/test_agent_response.py | 230 +++++----- fastapi_startkit/tests/ai/test_config.py | 190 +++----- fastapi_startkit/tests/ai/test_lab.py | 63 ++- fastapi_startkit/tests/ai/test_provider.py | 146 +++---- 8 files changed, 823 insertions(+), 895 deletions(-) create mode 100644 fastapi_startkit/tests/ai/test_agent.py diff --git a/fastapi_startkit/tests/ai/test_agent.py b/fastapi_startkit/tests/ai/test_agent.py new file mode 100644 index 00000000..dd06665f --- /dev/null +++ b/fastapi_startkit/tests/ai/test_agent.py @@ -0,0 +1,150 @@ +import unittest +from unittest import mock + +import langchain.chat_models as chat_models +from langchain_core.messages import AIMessage, ToolCall +from langchain_core.tools import tool + +from fastapi_startkit.ai import AIConfig, Document, fake_chat_model +from fastapi_startkit.ai.agent import Agent +from fastapi_startkit.ai.response import AgentResponse +from fastapi_startkit.application import app + + +@tool +def search_jobs(query: str) -> str: + """Search the job board for roles matching the query.""" + return "Python Developer at Shopify" + + +class JobAssistant(Agent): + _instructions = "You help users find jobs." + + def tools(self): + return [search_jobs] + + +class TestAgent(unittest.TestCase): + def setUp(self): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + + def setup_agent(self, turns: list[AIMessage]): + model = fake_chat_model(turns) + patcher = mock.patch.object(chat_models, "init_chat_model", lambda *a, **k: model) + patcher.start() + self.addCleanup(patcher.stop) + return model + + def test_prompt_returns_agent_response(self): + self.setup_agent([AIMessage(content="hello back")]) + + agent = Agent() + result = agent.prompt("hi there") + + self.assertIsInstance(result, AgentResponse) + self.assertEqual(result.content, "hello back") + agent.assert_prompted() + + def test_search_jobs_tool_returns_listing(self): + self.setup_agent( + [ + AIMessage( + content="", + tool_calls=[ToolCall(name="search_jobs", args={"query": "python"}, id="c1", type="tool_call")], + ), + ] + ) + + result = JobAssistant().prompt("find me a python job") + + self.assertEqual(result.content, "Python Developer at Shopify") + self.assertEqual(result.tool_calls, []) + + def test_prompt_maps_usage_metadata(self): + self.setup_agent( + [AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})] + ) + + result = Agent().prompt("anything") + + self.assertEqual(result.usage, {"input": 11, "output": 7}) + + def test_build_model_passes_langchain_provider_key(self): + captured = {} + + def fake_init(model, **kwargs): + captured["model"] = model + captured["provider"] = kwargs.get("model_provider") + return fake_chat_model([AIMessage(content="ok")]) + + patcher = mock.patch.object(chat_models, "init_chat_model", fake_init) + patcher.start() + self.addCleanup(patcher.stop) + + class GoogleAgent(Agent): + _provider = "google" + + GoogleAgent().prompt("hi") + + self.assertEqual(captured["provider"], "google_genai") + self.assertEqual(captured["model"], "gemini-2.0-flash") + + def test_stream_yields_tokens_from_the_model(self): + self.setup_agent([AIMessage(content="streamed reply")]) + + chunks = list(Agent().stream("hello")) + + self.assertEqual("".join(chunks), "streamed reply") + + def test_resolve_model_falls_back_to_lab_default(self): + self.assertEqual(Agent()._resolve_model(), "gemini-2.0-flash") + + class AnthropicAgent(Agent): + _provider = "anthropic" + + self.assertEqual(AnthropicAgent()._resolve_model(), "claude-sonnet-4-6") + + def test_resolve_model_prefers_explicit_override(self): + self.assertEqual(Agent()._resolve_model("my-model"), "my-model") + + def test_instructions_lead_the_message_list(self): + messages = JobAssistant()._build_messages("find me a job") + + self.assertEqual(messages[0], {"role": "system", "content": "You help users find jobs."}) + self.assertEqual(sum(m.get("role") == "system" for m in messages), 1) + + def test_instructions_can_be_a_method_override(self): + class DynamicAgent(Agent): + def instructions(self) -> str: + return "Computed identity." + + messages = DynamicAgent()._build_messages("hi") + + self.assertEqual(messages[0], {"role": "system", "content": "Computed identity."}) + + def test_no_instructions_prepends_no_system_message(self): + messages = Agent()._build_messages("hi") + + self.assertTrue(all(m.get("role") != "system" for m in messages)) + + def test_build_messages_inlines_text_attachment(self): + doc = Document(content="Q3 revenue was $1.2M.", name="q3-report.txt") + + messages = Agent()._build_messages("Summarise this report.", attachments=[doc]) + + user_content = messages[-1]["content"] + self.assertEqual(user_content[0], {"type": "text", "text": "Summarise this report."}) + self.assertEqual(user_content[1]["type"], "text") + self.assertIn("q3-report.txt", user_content[1]["text"]) + + def test_build_messages_encodes_binary_attachment_as_file_block(self): + doc = Document(content=b"%PDF-1.7 ...", name="q3.pdf", media_type="application/pdf") + + messages = Agent()._build_messages("Summarise", attachments=[doc]) + + block = messages[-1]["content"][1] + self.assertEqual(block["type"], "file") + self.assertEqual(block["mime_type"], "application/pdf") + self.assertEqual(block["base64"], doc.to_base64()) diff --git a/fastapi_startkit/tests/ai/test_agent_decorators.py b/fastapi_startkit/tests/ai/test_agent_decorators.py index 0ce11bc4..4b7508f5 100644 --- a/fastapi_startkit/tests/ai/test_agent_decorators.py +++ b/fastapi_startkit/tests/ai/test_agent_decorators.py @@ -1,5 +1,7 @@ """Tests for Agent class decorators.""" +import unittest + from fastapi_startkit.ai.agent import Agent from fastapi_startkit.ai.decorators import ( max_steps, @@ -12,195 +14,153 @@ ) -# ─── Decorator: @provider ────────────────────────────────────────────────────── - - -def test_provider_decorator_sets_provider(): - @provider("openai") - class MyAgent(Agent): - pass - - assert MyAgent._provider == "openai" - - -def test_provider_decorator_sets_anthropic(): - @provider("anthropic") - class MyAgent(Agent): - pass - - assert MyAgent._provider == "anthropic" - - -def test_provider_decorator_sets_google(): - @provider("google") - class MyAgent(Agent): - pass - - assert MyAgent._provider == "google" - - -# ─── Decorator: @model ──────────────────────────────────────────────────────── - - -def test_model_decorator_sets_model(): - @model("gpt-4o") - class MyAgent(Agent): - pass - - assert MyAgent._model == "gpt-4o" - - -def test_model_decorator_sets_claude_model(): - @model("claude-sonnet-4-6") - class MyAgent(Agent): - pass - - assert MyAgent._model == "claude-sonnet-4-6" - - -# ─── Decorator: @max_tokens ─────────────────────────────────────────────────── - - -def test_max_tokens_decorator_sets_value(): - @max_tokens(2048) - class MyAgent(Agent): - pass - - assert MyAgent._max_tokens == 2048 - - -def test_max_tokens_decorator_overrides_default(): - @max_tokens(512) - class MyAgent(Agent): - pass - - assert MyAgent._max_tokens == 512 - - -# ─── Decorator: @max_steps ──────────────────────────────────────────────────── - - -def test_max_steps_decorator_sets_value(): - @max_steps(5) - class MyAgent(Agent): - pass - - assert MyAgent._max_steps == 5 - - -def test_max_steps_decorator_sets_one(): - @max_steps(1) - class MyAgent(Agent): - pass - - assert MyAgent._max_steps == 1 +class TestAgentDecorators(unittest.TestCase): + def test_provider_decorator_sets_provider(self): + @provider("openai") + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._provider, "openai") -# ─── Decorator: @timeout ────────────────────────────────────────────────────── + def test_provider_decorator_sets_anthropic(self): + @provider("anthropic") + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._provider, "anthropic") -def test_timeout_decorator_sets_seconds(): - @timeout(60.0) - class MyAgent(Agent): - pass + def test_provider_decorator_sets_google(self): + @provider("google") + class MyAgent(Agent): + pass - assert MyAgent._timeout == 60.0 + self.assertEqual(MyAgent._provider, "google") + def test_model_decorator_sets_model(self): + @model("gpt-4o") + class MyAgent(Agent): + pass -def test_timeout_decorator_sets_fractional(): - @timeout(2.5) - class MyAgent(Agent): - pass + self.assertEqual(MyAgent._model, "gpt-4o") - assert MyAgent._timeout == 2.5 + def test_model_decorator_sets_claude_model(self): + @model("claude-sonnet-4-6") + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._model, "claude-sonnet-4-6") -# ─── Decorator: @top_p ──────────────────────────────────────────────────────── + def test_max_tokens_decorator_sets_value(self): + @max_tokens(2048) + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._max_tokens, 2048) -def test_top_p_decorator_sets_value(): - @top_p(0.9) - class MyAgent(Agent): - pass + def test_max_tokens_decorator_overrides_default(self): + @max_tokens(512) + class MyAgent(Agent): + pass - assert MyAgent._top_p == 0.9 + self.assertEqual(MyAgent._max_tokens, 512) + def test_max_steps_decorator_sets_value(self): + @max_steps(5) + class MyAgent(Agent): + pass -def test_top_p_decorator_sets_zero(): - @top_p(0.0) - class MyAgent(Agent): - pass + self.assertEqual(MyAgent._max_steps, 5) - assert MyAgent._top_p == 0.0 + def test_max_steps_decorator_sets_one(self): + @max_steps(1) + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._max_steps, 1) -# ─── Decorator: @memory ─────────────────────────────────────────────────────── + def test_timeout_decorator_sets_seconds(self): + @timeout(60.0) + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._timeout, 60.0) -def test_memory_decorator_sets_backend(): - @memory("redis") - class MyAgent(Agent): - pass + def test_timeout_decorator_sets_fractional(self): + @timeout(2.5) + class MyAgent(Agent): + pass - assert MyAgent._memory_backend == "redis" + self.assertEqual(MyAgent._timeout, 2.5) + def test_top_p_decorator_sets_value(self): + @top_p(0.9) + class MyAgent(Agent): + pass -def test_memory_decorator_sets_custom_backend(): - @memory("postgres") - class MyAgent(Agent): - pass + self.assertEqual(MyAgent._top_p, 0.9) - assert MyAgent._memory_backend == "postgres" + def test_top_p_decorator_sets_zero(self): + @top_p(0.0) + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._top_p, 0.0) -# ─── Stacking multiple decorators ───────────────────────────────────────────── + def test_memory_decorator_sets_backend(self): + @memory("redis") + class MyAgent(Agent): + pass + self.assertEqual(MyAgent._memory_backend, "redis") -def test_multiple_decorators_can_be_stacked(): - @provider("openai") - @model("gpt-4o") - @max_tokens(1024) - @max_steps(3) - @timeout(15.0) - @top_p(0.95) - @memory("redis") - class FullyConfiguredAgent(Agent): - pass + def test_memory_decorator_sets_custom_backend(self): + @memory("postgres") + class MyAgent(Agent): + pass - assert FullyConfiguredAgent._provider == "openai" - assert FullyConfiguredAgent._model == "gpt-4o" - assert FullyConfiguredAgent._max_tokens == 1024 - assert FullyConfiguredAgent._max_steps == 3 - assert FullyConfiguredAgent._timeout == 15.0 - assert FullyConfiguredAgent._top_p == 0.95 - assert FullyConfiguredAgent._memory_backend == "redis" + self.assertEqual(MyAgent._memory_backend, "postgres") + def test_multiple_decorators_can_be_stacked(self): + @provider("openai") + @model("gpt-4o") + @max_tokens(1024) + @max_steps(3) + @timeout(15.0) + @top_p(0.95) + @memory("redis") + class FullyConfiguredAgent(Agent): + pass -def test_stacking_does_not_affect_base_class(): - """Decorator-applied values must not leak into the Agent base class.""" + self.assertEqual(FullyConfiguredAgent._provider, "openai") + self.assertEqual(FullyConfiguredAgent._model, "gpt-4o") + self.assertEqual(FullyConfiguredAgent._max_tokens, 1024) + self.assertEqual(FullyConfiguredAgent._max_steps, 3) + self.assertEqual(FullyConfiguredAgent._timeout, 15.0) + self.assertEqual(FullyConfiguredAgent._top_p, 0.95) + self.assertEqual(FullyConfiguredAgent._memory_backend, "redis") - @provider("openai") - @model("gpt-4o") - class SubAgent(Agent): - pass + def test_stacking_does_not_affect_base_class(self): + """Decorator-applied values must not leak into the Agent base class.""" - # Base Agent must retain its own defaults - assert Agent._provider == "anthropic" - assert Agent._model == "" + @provider("openai") + @model("gpt-4o") + class SubAgent(Agent): + pass - # Subclass has decorated values - assert SubAgent._provider == "openai" - assert SubAgent._model == "gpt-4o" + self.assertIsNone(Agent._provider) + self.assertIsNone(Agent._model) + self.assertEqual(SubAgent._provider, "openai") + self.assertEqual(SubAgent._model, "gpt-4o") -def test_instance_inherits_class_config(): - """Instantiating a decorated class reads the right config values.""" + def test_instance_inherits_class_config(self): + """Instantiating a decorated class reads the right config values.""" - @provider("openai") - @max_tokens(256) - class TinyAgent(Agent): - pass + @provider("openai") + @max_tokens(256) + class TinyAgent(Agent): + pass - agent = TinyAgent() - assert agent._provider == "openai" - assert agent._max_tokens == 256 + agent = TinyAgent() + self.assertEqual(agent._provider, "openai") + self.assertEqual(agent._max_tokens, 256) diff --git a/fastapi_startkit/tests/ai/test_agent_document.py b/fastapi_startkit/tests/ai/test_agent_document.py index 997ed0d9..264e5e55 100644 --- a/fastapi_startkit/tests/ai/test_agent_document.py +++ b/fastapi_startkit/tests/ai/test_agent_document.py @@ -2,150 +2,126 @@ import os import tempfile +import unittest -import pytest from fastapi_startkit.ai.document import Document -# ─── Document.from_path() ───────────────────────────────────────────────────── - - -def test_from_path_reads_file_content(): - content = "Hello from file!" - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write(content) - path = f.name - - try: - doc = Document.from_path(path) - assert doc.content == content - finally: - os.unlink(path) - - -def test_from_path_sets_name_to_path(): - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write("content") - path = f.name - - try: - doc = Document.from_path(path) - assert doc.name == path - finally: - os.unlink(path) - - -def test_from_path_reads_multiline_content(): - content = "line one\nline two\nline three\n" - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write(content) - path = f.name - - try: - doc = Document.from_path(path) - assert doc.content == content - finally: - os.unlink(path) - - -def test_from_path_raises_on_missing_file(): - with pytest.raises(FileNotFoundError): - Document.from_path("/nonexistent/path/file.txt") - - -def test_from_path_reads_empty_file(): - with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: - f.write("") - path = f.name - - try: - doc = Document.from_path(path) - assert doc.content == "" - finally: - os.unlink(path) - - -# ─── Document.to_anthropic_block() ──────────────────────────────────────────── - - -def test_to_anthropic_block_returns_dict(): - doc = Document(content="some text", name="report.txt") - block = doc.to_anthropic_block() - assert isinstance(block, dict) - - -def test_to_anthropic_block_has_type_document(): - doc = Document(content="some text", name="report.txt") - block = doc.to_anthropic_block() - assert block["type"] == "document" - - -def test_to_anthropic_block_has_source_key(): - doc = Document(content="some text") - block = doc.to_anthropic_block() - assert "source" in block - - -def test_to_anthropic_block_source_type_is_text(): - doc = Document(content="some text") - block = doc.to_anthropic_block() - assert block["source"]["type"] == "text" - - -def test_to_anthropic_block_source_media_type_default(): - doc = Document(content="some text") - block = doc.to_anthropic_block() - assert block["source"]["media_type"] == "text/plain" - - -def test_to_anthropic_block_source_data_contains_content(): - doc = Document(content="the actual text content") - block = doc.to_anthropic_block() - assert block["source"]["data"] == "the actual text content" - - -def test_to_anthropic_block_title_is_name(): - doc = Document(content="text", name="my_document.txt") - block = doc.to_anthropic_block() - assert block["title"] == "my_document.txt" - - -def test_to_anthropic_block_custom_media_type(): - doc = Document(content="", name="page.html", media_type="text/html") - block = doc.to_anthropic_block() - assert block["source"]["media_type"] == "text/html" - - -def test_to_anthropic_block_full_structure(): - """Assert the complete expected block structure matches exactly.""" - doc = Document(content="contract text", name="contract.txt", media_type="text/plain") - block = doc.to_anthropic_block() - - expected = { - "type": "document", - "source": { - "type": "text", - "media_type": "text/plain", - "data": "contract text", - }, - "title": "contract.txt", - } - assert block == expected - - -# ─── Document constructor ────────────────────────────────────────────────────── - - -def test_document_name_defaults_to_empty_string(): - doc = Document(content="text") - assert doc.name == "" - - -def test_document_media_type_defaults_to_text_plain(): - doc = Document(content="text") - assert doc.media_type == "text/plain" - - -def test_document_stores_content(): - doc = Document(content="stored content") - assert doc.content == "stored content" +class TestDocument(unittest.TestCase): + def test_from_path_reads_file_content(self): + content = "Hello from file!" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(content) + path = f.name + + try: + doc = Document.from_path(path) + self.assertEqual(doc.content, content) + finally: + os.unlink(path) + + def test_from_path_sets_name_to_path(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("content") + path = f.name + + try: + doc = Document.from_path(path) + self.assertEqual(doc.name, path) + finally: + os.unlink(path) + + def test_from_path_reads_multiline_content(self): + content = "line one\nline two\nline three\n" + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write(content) + path = f.name + + try: + doc = Document.from_path(path) + self.assertEqual(doc.content, content) + finally: + os.unlink(path) + + def test_from_path_raises_on_missing_file(self): + with self.assertRaises(FileNotFoundError): + Document.from_path("/nonexistent/path/file.txt") + + def test_from_path_reads_empty_file(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: + f.write("") + path = f.name + + try: + doc = Document.from_path(path) + self.assertEqual(doc.content, "") + finally: + os.unlink(path) + + def test_to_anthropic_block_returns_dict(self): + doc = Document(content="some text", name="report.txt") + block = doc.to_anthropic_block() + self.assertIsInstance(block, dict) + + def test_to_anthropic_block_has_type_document(self): + doc = Document(content="some text", name="report.txt") + block = doc.to_anthropic_block() + self.assertEqual(block["type"], "document") + + def test_to_anthropic_block_has_source_key(self): + doc = Document(content="some text") + block = doc.to_anthropic_block() + self.assertIn("source", block) + + def test_to_anthropic_block_source_type_is_text(self): + doc = Document(content="some text") + block = doc.to_anthropic_block() + self.assertEqual(block["source"]["type"], "text") + + def test_to_anthropic_block_source_media_type_default(self): + doc = Document(content="some text") + block = doc.to_anthropic_block() + self.assertEqual(block["source"]["media_type"], "text/plain") + + def test_to_anthropic_block_source_data_contains_content(self): + doc = Document(content="the actual text content") + block = doc.to_anthropic_block() + self.assertEqual(block["source"]["data"], "the actual text content") + + def test_to_anthropic_block_title_is_name(self): + doc = Document(content="text", name="my_document.txt") + block = doc.to_anthropic_block() + self.assertEqual(block["title"], "my_document.txt") + + def test_to_anthropic_block_custom_media_type(self): + doc = Document(content="", name="page.html", media_type="text/html") + block = doc.to_anthropic_block() + self.assertEqual(block["source"]["media_type"], "text/html") + + def test_to_anthropic_block_full_structure(self): + """Assert the complete expected block structure matches exactly.""" + doc = Document(content="contract text", name="contract.txt", media_type="text/plain") + block = doc.to_anthropic_block() + + expected = { + "type": "document", + "source": { + "type": "text", + "media_type": "text/plain", + "data": "contract text", + }, + "title": "contract.txt", + } + self.assertEqual(block, expected) + + def test_document_name_defaults_to_empty_string(self): + doc = Document(content="text") + self.assertEqual(doc.name, "") + + def test_document_media_type_defaults_to_text_plain(self): + doc = Document(content="text") + self.assertEqual(doc.media_type, "text/plain") + + def test_document_stores_content(self): + doc = Document(content="stored content") + self.assertEqual(doc.content, "stored content") diff --git a/fastapi_startkit/tests/ai/test_agent_fake.py b/fastapi_startkit/tests/ai/test_agent_fake.py index ff52563c..09bd685a 100644 --- a/fastapi_startkit/tests/ai/test_agent_fake.py +++ b/fastapi_startkit/tests/ai/test_agent_fake.py @@ -1,276 +1,237 @@ -"""Tests for Agent.fake(), assert_prompted(), assert_not_prompted(), and reset().""" +"""Tests for Agent.fake() / Agent.record() and the assert_prompted/reset helpers. + +``Agent.fake()`` binds a canned stand-in into the container for the duration of a +``with`` block. ``Agent.record()`` binds a record-and-replay stand-in: on a cassette +miss it calls the real agent once and caches the response to disk; on a hit it +replays from the cassette without calling the agent again. +""" import json import os import tempfile +import unittest +from unittest import mock -import pytest from fastapi_startkit.ai.agent import Agent -from fastapi_startkit.ai.response import AgentResponse, AgentSnapshot - - -# ─── Helpers ────────────────────────────────────────────────────────────────── +from fastapi_startkit.ai.response import AgentResponse class SimpleAgent(Agent): - """Bare-minimum agent for testing — never touches a real API.""" - pass -# ─── fake() with AgentResponse returns it without hitting the API ────────────── - - -def test_fake_with_agent_response_returns_it(): - agent = SimpleAgent() - expected = AgentResponse(content="Hello world!") - agent.fake({"*": expected}) - - result = agent.prompt("anything") - - assert result.content == "Hello world!" - - -def test_fake_does_not_call_provider_run(): - """fake() must short-circuit before _run() is ever invoked.""" - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="faked")}) - - called = [] - - original_run = agent._run - - def patched_run(*args, **kwargs): - called.append(True) - return original_run(*args, **kwargs) - - agent._run = patched_run # type: ignore[method-assign] - agent.prompt("hello") - - assert called == [], "_run() must not be called when a fake matches" - - -def test_fake_with_exact_pattern(): - agent = SimpleAgent() - agent.fake({"hello": AgentResponse(content="matched hello")}) - - result = agent.prompt("hello") - assert result.content == "matched hello" - - -# ─── fake() with glob pattern matching ──────────────────────────────────────── - - -def test_fake_glob_hello_wildcard(): - agent = SimpleAgent() - agent.fake({"*hello*": AgentResponse(content="hi there")}) - - result = agent.prompt("say hello to me") - assert result.content == "hi there" - - -def test_fake_glob_analyze_wildcard(): - agent = SimpleAgent() - agent.fake({"*analyze*": AgentResponse(content="analysis done")}) - - result = agent.prompt("please analyze this report") - assert result.content == "analysis done" - - -def test_fake_glob_no_match_raises_on_missing_run(): - """When a pattern does not match and no real provider is configured, _run raises.""" - agent = SimpleAgent() - agent.fake({"*hello*": AgentResponse(content="hi")}) - - with pytest.raises(Exception): - agent.prompt("goodbye") # pattern does not match → falls through to _run - - -def test_fake_glob_case_insensitive(): - agent = SimpleAgent() - agent.fake({"*HELLO*": AgentResponse(content="case insensitive")}) - - result = agent.prompt("say hello please") - assert result.content == "case insensitive" - - -def test_fake_first_matching_pattern_wins(): - agent = SimpleAgent() - agent.fake( - { - "*hello*": AgentResponse(content="first match"), - "*hello world*": AgentResponse(content="second match"), - } - ) - - result = agent.prompt("hello world") - assert result.content == "first match" - - -# ─── fake() with AgentSnapshot loads from fixture if file exists ─────────────── - - -def test_fake_with_snapshot_loads_from_file_if_exists(): - fixture_data = {"content": "snapshot reply", "tool_calls": [], "usage": {"input": 5, "output": 10}} - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(fixture_data, f) - fixture_path = f.name - - try: +class TestAgentFake(unittest.TestCase): + def test_fake_with_agent_response_returns_it(self): agent = SimpleAgent() - snapshot = AgentSnapshot(path=fixture_path) - agent.fake({"*": snapshot}) - - result = agent.prompt("any prompt") - assert result.content == "snapshot reply" - assert result.usage == {"input": 5, "output": 10} - finally: - os.unlink(fixture_path) - + with SimpleAgent.fake({"*": AgentResponse(content="Hello world!")}): + result = agent.prompt("anything") -def test_fake_with_snapshot_missing_file_calls_run(monkeypatch): - """When the snapshot file does not exist, _run() is called and the result is saved.""" - expected_response = AgentResponse(content="live result") - - with tempfile.TemporaryDirectory() as tmpdir: - snapshot_path = os.path.join(tmpdir, "snap.json") + self.assertEqual(result.content, "Hello world!") + def test_fake_does_not_call_provider_run(self): agent = SimpleAgent() - snapshot = AgentSnapshot(path=snapshot_path) - agent.fake({"*": snapshot}) - - # Patch _run to avoid real API call - monkeypatch.setattr(agent, "_run", lambda *a, **kw: expected_response) + called = [] - result = agent.prompt("test message") - assert result.content == "live result" + original_run = agent._run - # Snapshot should now be saved to disk - assert os.path.exists(snapshot_path) - with open(snapshot_path) as f: - saved = json.load(f) - assert saved["content"] == "live result" + def patched_run(*args, **kwargs): + called.append(True) + return original_run(*args, **kwargs) + agent._run = patched_run -# ─── assert_prompted() ──────────────────────────────────────────────────────── + with SimpleAgent.fake({"*": AgentResponse(content="faked")}): + agent.prompt("hello") + self.assertEqual(called, [], "_run() must not be called when a fake matches") -def test_assert_prompted_passes_after_one_call(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) - - agent.prompt("first") - agent.assert_prompted() # must not raise - - -def test_assert_prompted_times_2_passes_after_exactly_2_calls(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) + def test_fake_with_exact_pattern(self): + agent = SimpleAgent() + with SimpleAgent.fake({"hello": AgentResponse(content="matched hello")}): + result = agent.prompt("hello") - agent.prompt("first") - agent.prompt("second") - agent.assert_prompted(times=2) # must not raise + self.assertEqual(result.content, "matched hello") + def test_fake_glob_hello_wildcard(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*hello*": AgentResponse(content="hi there")}): + result = agent.prompt("say hello to me") -def test_assert_prompted_times_fails_when_count_mismatch(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) + self.assertEqual(result.content, "hi there") - agent.prompt("only once") + def test_fake_glob_analyze_wildcard(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*analyze*": AgentResponse(content="analysis done")}): + result = agent.prompt("please analyze this report") - with pytest.raises(AssertionError): - agent.assert_prompted(times=2) + self.assertEqual(result.content, "analysis done") + def test_fake_no_match_raises(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*hello*": AgentResponse(content="hi")}): + with self.assertRaises(Exception): + agent.prompt("goodbye") -def test_assert_prompted_fails_when_never_called(): - agent = SimpleAgent() + def test_fake_glob_case_insensitive(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*HELLO*": AgentResponse(content="case insensitive")}): + result = agent.prompt("say hello please") - with pytest.raises(AssertionError): - agent.assert_prompted() + self.assertEqual(result.content, "case insensitive") + def test_fake_first_matching_pattern_wins(self): + agent = SimpleAgent() + with SimpleAgent.fake( + { + "*hello*": AgentResponse(content="first match"), + "*hello world*": AgentResponse(content="second match"), + } + ): + result = agent.prompt("hello world") -def test_assert_prompted_times_zero_passes_when_never_called(): - agent = SimpleAgent() - agent.assert_prompted(times=0) # must not raise + self.assertEqual(result.content, "first match") + def test_assert_prompted_passes_after_one_call(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("first") + agent.assert_prompted() -# ─── assert_not_prompted() ──────────────────────────────────────────────────── + def test_assert_prompted_times_2_passes_after_exactly_2_calls(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("first") + agent.prompt("second") + agent.assert_prompted(times=2) + def test_assert_prompted_times_fails_when_count_mismatch(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("only once") -def test_assert_not_prompted_passes_when_no_calls_made(): - agent = SimpleAgent() - agent.assert_not_prompted() # must not raise + with self.assertRaises(AssertionError): + agent.assert_prompted(times=2) + def test_assert_prompted_fails_when_never_called(self): + agent = SimpleAgent() -def test_assert_not_prompted_fails_after_one_call(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) + with self.assertRaises(AssertionError): + agent.assert_prompted() - agent.prompt("a prompt") + def test_assert_prompted_times_zero_passes_when_never_called(self): + agent = SimpleAgent() + agent.assert_prompted(times=0) - with pytest.raises(AssertionError): + def test_assert_not_prompted_passes_when_no_calls_made(self): + agent = SimpleAgent() agent.assert_not_prompted() + def test_assert_not_prompted_fails_after_one_call(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("a prompt") -# ─── reset() ────────────────────────────────────────────────────────────────── - - -def test_reset_clears_call_log(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) - - agent.prompt("first") - assert len(agent._call_log) == 1 - - agent.reset() - assert agent._call_log == [] - - -def test_reset_clears_fakes(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) - assert len(agent._fakes) == 1 - - agent.reset() - assert agent._fakes == {} - - -def test_reset_returns_agent_for_chaining(): - agent = SimpleAgent() - result = agent.reset() - assert result is agent - - -def test_assert_not_prompted_passes_after_reset(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="ok")}) - - agent.prompt("call before reset") - agent.reset() - - agent.assert_not_prompted() # call log was cleared + with self.assertRaises(AssertionError): + agent.assert_not_prompted() + def test_reset_clears_call_log(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("first") + self.assertEqual(len(agent._call_log), 1) -def test_fake_after_reset_works_normally(): - agent = SimpleAgent() - agent.fake({"*": AgentResponse(content="first fake")}) - agent.prompt("call") - agent.reset() + agent.reset() + self.assertEqual(agent._call_log, []) - agent.fake({"*": AgentResponse(content="second fake")}) - result = agent.prompt("call again") - assert result.content == "second fake" + def test_reset_returns_agent_for_chaining(self): + agent = SimpleAgent() + result = agent.reset() + self.assertIs(result, agent) + def test_assert_not_prompted_passes_after_reset(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*": AgentResponse(content="ok")}): + agent.prompt("call before reset") -# ─── stream() respects fake() ───────────────────────────────────────────────── + agent.reset() + agent.assert_not_prompted() + def test_fake_rebinding_overrides_previous(self): + agent = SimpleAgent() -def test_stream_returns_fake_response(): - agent = SimpleAgent() - agent.fake({"*hello*": AgentResponse(content="Faked stream!")}) + with SimpleAgent.fake({"*": AgentResponse(content="first fake")}): + self.assertEqual(agent.prompt("call").content, "first fake") - chunks = list(agent.stream("hello world")) + with SimpleAgent.fake({"*": AgentResponse(content="second fake")}): + self.assertEqual(agent.prompt("call again").content, "second fake") - assert chunks == ["Faked stream!"] - agent.assert_prompted(times=1) + def test_stream_returns_fake_response(self): + agent = SimpleAgent() + with SimpleAgent.fake({"*hello*": AgentResponse(content="Faked stream!")}): + chunks = list(agent.stream("hello world")) + + self.assertEqual(chunks, ["Faked stream!"]) + agent.assert_prompted(times=1) + + +class TestAgentRecord(unittest.TestCase): + def setup_agent(self, content): + calls = [] + + def fake_run(agent_self, message, **kwargs): + calls.append(message) + return AgentResponse(content=content) + + patcher = mock.patch.object(SimpleAgent, "_run", fake_run) + patcher.start() + self.addCleanup(patcher.stop) + return calls + + def test_first_run_records_response_to_cassette(self): + calls = self.setup_agent("recorded reply") + with tempfile.TemporaryDirectory() as tmp: + cassette = os.path.join(tmp, "c.json") + with SimpleAgent.record(cassette): + result = SimpleAgent().prompt("hello") + + self.assertEqual(result.content, "recorded reply") + self.assertEqual(calls, ["hello"]) + self.assertTrue(os.path.exists(cassette)) + with open(cassette) as f: + self.assertIn("recorded reply", json.load(f).values()) + + def test_second_run_replays_without_calling_run(self): + calls = self.setup_agent("recorded reply") + with tempfile.TemporaryDirectory() as tmp: + cassette = os.path.join(tmp, "c.json") + with SimpleAgent.record(cassette): + SimpleAgent().prompt("hello") + with SimpleAgent.record(cassette): + replayed = SimpleAgent().prompt("hello") + + self.assertEqual(replayed.content, "recorded reply") + self.assertEqual(calls, ["hello"]) + + def test_replay_prefers_cassette_over_live_response(self): + with tempfile.TemporaryDirectory() as tmp: + cassette = os.path.join(tmp, "c.json") + with mock.patch.object(SimpleAgent, "_run", lambda s, m, **k: AgentResponse(content="from first record")): + with SimpleAgent.record(cassette): + SimpleAgent().prompt("hello") + with mock.patch.object(SimpleAgent, "_run", lambda s, m, **k: AgentResponse(content="changed live value")): + with SimpleAgent.record(cassette): + result = SimpleAgent().prompt("hello") + + self.assertEqual(result.content, "from first record") + + def test_distinct_messages_are_recorded_separately(self): + calls = self.setup_agent("reply") + with tempfile.TemporaryDirectory() as tmp: + cassette = os.path.join(tmp, "c.json") + with SimpleAgent.record(cassette): + SimpleAgent().prompt("hello") + SimpleAgent().prompt("goodbye") + + self.assertEqual(calls, ["hello", "goodbye"]) + with open(cassette) as f: + self.assertEqual(len(json.load(f)), 2) diff --git a/fastapi_startkit/tests/ai/test_agent_response.py b/fastapi_startkit/tests/ai/test_agent_response.py index d57f7b5b..0d203076 100644 --- a/fastapi_startkit/tests/ai/test_agent_response.py +++ b/fastapi_startkit/tests/ai/test_agent_response.py @@ -1,136 +1,104 @@ """Tests for the AgentResponse dataclass.""" -import pytest -from fastapi_startkit.ai.response import AgentResponse - - -# ─── AgentResponse.text() ───────────────────────────────────────────────────── - - -def test_text_returns_content(): - response = AgentResponse(content="Hello, world!") - assert response.text() == "Hello, world!" - - -def test_text_returns_empty_string_when_no_content(): - response = AgentResponse() - assert response.text() == "" - - -def test_text_returns_multiline_content(): - content = "Line 1\nLine 2\nLine 3" - response = AgentResponse(content=content) - assert response.text() == content - - -# ─── AgentResponse.json() ───────────────────────────────────────────────────── - - -def test_json_parses_content_as_json(): - response = AgentResponse(content='{"key": "value", "number": 42}') - parsed = response.json() - assert parsed == {"key": "value", "number": 42} - - -def test_json_parses_list_content(): - response = AgentResponse(content="[1, 2, 3]") - assert response.json() == [1, 2, 3] - - -def test_json_parses_nested_object(): - response = AgentResponse(content='{"nested": {"a": 1}}') - assert response.json()["nested"]["a"] == 1 - - -def test_json_raises_on_invalid_content(): - response = AgentResponse(content="not valid json") - with pytest.raises(Exception): # json.JSONDecodeError - response.json() - - -def test_json_raises_on_empty_content(): - response = AgentResponse(content="") - with pytest.raises(Exception): - response.json() - - -# ─── AgentResponse.__str__() ────────────────────────────────────────────────── - - -def test_str_returns_content(): - response = AgentResponse(content="My response text") - assert str(response) == "My response text" +import unittest - -def test_str_returns_empty_string_when_no_content(): - response = AgentResponse() - assert str(response) == "" - - -def test_str_works_in_f_string(): - response = AgentResponse(content="hello") - assert f"Result: {response}" == "Result: hello" - - -# ─── AgentResponse.__bool__() ───────────────────────────────────────────────── - - -def test_bool_is_true_when_content_non_empty(): - response = AgentResponse(content="some text") - assert bool(response) is True - - -def test_bool_is_false_when_content_empty(): - response = AgentResponse(content="") - assert bool(response) is False - - -def test_bool_is_false_when_content_not_set(): - response = AgentResponse() - assert bool(response) is False - - -def test_bool_is_true_with_whitespace_content(): - """A response with only whitespace still evaluates as truthy (non-empty string).""" - response = AgentResponse(content=" ") - assert bool(response) is True - - -def test_bool_usable_in_conditional(): - response = AgentResponse(content="text") - assert response # truthy - - empty = AgentResponse(content="") - assert not empty # falsy - - -# ─── AgentResponse dataclass fields ─────────────────────────────────────────── - - -def test_tool_calls_default_to_empty_list(): - response = AgentResponse() - assert response.tool_calls == [] - - -def test_usage_defaults_to_empty_dict(): - response = AgentResponse() - assert response.usage == {} - - -def test_raw_defaults_to_none(): - response = AgentResponse() - assert response.raw is None +from fastapi_startkit.ai.response import AgentResponse -def test_all_fields_can_be_set(): - raw_obj = object() - response = AgentResponse( - content="text", - tool_calls=[{"name": "search", "input": {"q": "test"}}], - usage={"input": 10, "output": 20}, - raw=raw_obj, - ) - assert response.content == "text" - assert response.tool_calls == [{"name": "search", "input": {"q": "test"}}] - assert response.usage == {"input": 10, "output": 20} - assert response.raw is raw_obj +class TestAgentResponse(unittest.TestCase): + def test_text_returns_content(self): + response = AgentResponse(content="Hello, world!") + self.assertEqual(response.text(), "Hello, world!") + + def test_text_returns_empty_string_when_no_content(self): + response = AgentResponse() + self.assertEqual(response.text(), "") + + def test_text_returns_multiline_content(self): + content = "Line 1\nLine 2\nLine 3" + response = AgentResponse(content=content) + self.assertEqual(response.text(), content) + + def test_json_parses_content_as_json(self): + response = AgentResponse(content='{"key": "value", "number": 42}') + parsed = response.json() + self.assertEqual(parsed, {"key": "value", "number": 42}) + + def test_json_parses_list_content(self): + response = AgentResponse(content="[1, 2, 3]") + self.assertEqual(response.json(), [1, 2, 3]) + + def test_json_parses_nested_object(self): + response = AgentResponse(content='{"nested": {"a": 1}}') + self.assertEqual(response.json()["nested"]["a"], 1) + + def test_json_raises_on_invalid_content(self): + response = AgentResponse(content="not valid json") + with self.assertRaises(Exception): # json.JSONDecodeError + response.json() + + def test_json_raises_on_empty_content(self): + response = AgentResponse(content="") + with self.assertRaises(Exception): + response.json() + + def test_str_returns_content(self): + response = AgentResponse(content="My response text") + self.assertEqual(str(response), "My response text") + + def test_str_returns_empty_string_when_no_content(self): + response = AgentResponse() + self.assertEqual(str(response), "") + + def test_str_works_in_f_string(self): + response = AgentResponse(content="hello") + self.assertEqual(f"Result: {response}", "Result: hello") + + def test_bool_is_true_when_content_non_empty(self): + response = AgentResponse(content="some text") + self.assertIs(bool(response), True) + + def test_bool_is_false_when_content_empty(self): + response = AgentResponse(content="") + self.assertIs(bool(response), False) + + def test_bool_is_false_when_content_not_set(self): + response = AgentResponse() + self.assertIs(bool(response), False) + + def test_bool_is_true_with_whitespace_content(self): + """A response with only whitespace still evaluates as truthy (non-empty string).""" + response = AgentResponse(content=" ") + self.assertIs(bool(response), True) + + def test_bool_usable_in_conditional(self): + response = AgentResponse(content="text") + self.assertTrue(response) + + empty = AgentResponse(content="") + self.assertFalse(empty) + + def test_tool_calls_default_to_empty_list(self): + response = AgentResponse() + self.assertEqual(response.tool_calls, []) + + def test_usage_defaults_to_empty_dict(self): + response = AgentResponse() + self.assertEqual(response.usage, {}) + + def test_raw_defaults_to_none(self): + response = AgentResponse() + self.assertIsNone(response.raw) + + def test_all_fields_can_be_set(self): + raw_obj = object() + response = AgentResponse( + content="text", + tool_calls=[{"name": "search", "input": {"q": "test"}}], + usage={"input": 10, "output": 20}, + raw=raw_obj, + ) + self.assertEqual(response.content, "text") + self.assertEqual(response.tool_calls, [{"name": "search", "input": {"q": "test"}}]) + self.assertEqual(response.usage, {"input": 10, "output": 20}) + self.assertIs(response.raw, raw_obj) diff --git a/fastapi_startkit/tests/ai/test_config.py b/fastapi_startkit/tests/ai/test_config.py index adbfcd8d..46dd58b9 100644 --- a/fastapi_startkit/tests/ai/test_config.py +++ b/fastapi_startkit/tests/ai/test_config.py @@ -1,149 +1,97 @@ """Tests for AI configuration dataclasses.""" -from fastapi_startkit.ai import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig - - -# ─── AIConfig defaults ──────────────────────────────────────────────────────── - - -def test_aiconfig_default_provider_is_google(monkeypatch): - """Default provider is 'google' when AI_PROVIDER env var is not set.""" - monkeypatch.delenv("AI_PROVIDER", raising=False) - config = AIConfig() - assert config.default == "google" - - -def test_aiconfig_default_reads_ai_provider_env(monkeypatch): - monkeypatch.setenv("AI_PROVIDER", "anthropic") - config = AIConfig() - assert config.default == "anthropic" - - -def test_aiconfig_providers_has_anthropic_key(): - config = AIConfig() - assert "anthropic" in config.providers - - -def test_aiconfig_providers_has_openai_key(): - config = AIConfig() - assert "openai" in config.providers - - -def test_aiconfig_providers_has_google_key(): - config = AIConfig() - assert "google" in config.providers - - -def test_aiconfig_providers_anthropic_is_instance(): - config = AIConfig() - assert isinstance(config.providers["anthropic"], AnthropicConfig) - - -def test_aiconfig_providers_openai_is_instance(): - config = AIConfig() - assert isinstance(config.providers["openai"], OpenAIConfig) - - -def test_aiconfig_providers_google_is_instance(): - config = AIConfig() - assert isinstance(config.providers["google"], GoogleConfig) - - -# ─── AnthropicConfig ────────────────────────────────────────────────────────── - - -def test_anthropic_config_driver_is_anthropic(): - config = AnthropicConfig() - assert config.driver == "anthropic" - - -def test_anthropic_config_picks_up_api_key_from_env(monkeypatch): - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-anthropic-key-123") - config = AnthropicConfig() - assert config.key == "test-anthropic-key-123" - - -def test_anthropic_config_key_defaults_to_empty_when_env_not_set(monkeypatch): - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - config = AnthropicConfig() - assert config.key == "" - - -def test_anthropic_config_url_default(): - config = AnthropicConfig() - assert config.url == "https://api.anthropic.com" +import os +import unittest +from unittest import mock +from fastapi_startkit.ai import AIConfig, AnthropicConfig, GoogleConfig, OpenAIConfig -def test_anthropic_config_url_can_be_overridden(monkeypatch): - monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://my-proxy.example.com") - config = AnthropicConfig() - assert config.url == "https://my-proxy.example.com" +class TestAIConfiguration(unittest.TestCase): + def _patch_env(self, set_=None, unset=()): + patcher = mock.patch.dict(os.environ, set_ or {}) + patcher.start() + self.addCleanup(patcher.stop) + for key in unset: + os.environ.pop(key, None) -# ─── OpenAIConfig ───────────────────────────────────────────────────────────── + def test_aiconfig_default_provider_is_google(self): + self._patch_env(unset=["AI_PROVIDER"]) + self.assertEqual(AIConfig().default, "google") + def test_aiconfig_default_reads_ai_provider_env(self): + self._patch_env({"AI_PROVIDER": "anthropic"}) + self.assertEqual(AIConfig().default, "anthropic") -def test_openai_config_driver_is_openai(): - config = OpenAIConfig() - assert config.driver == "openai" + def test_aiconfig_providers_has_anthropic_key(self): + self.assertIn("anthropic", AIConfig().providers) + def test_aiconfig_providers_has_openai_key(self): + self.assertIn("openai", AIConfig().providers) -def test_openai_config_picks_up_api_key_from_env(monkeypatch): - monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-test-key") - config = OpenAIConfig() - assert config.key == "sk-openai-test-key" + def test_aiconfig_providers_has_google_key(self): + self.assertIn("google", AIConfig().providers) + def test_aiconfig_providers_anthropic_is_instance(self): + self.assertIsInstance(AIConfig().providers["anthropic"], AnthropicConfig) -def test_openai_config_key_defaults_to_empty_when_env_not_set(monkeypatch): - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - config = OpenAIConfig() - assert config.key == "" + def test_aiconfig_providers_openai_is_instance(self): + self.assertIsInstance(AIConfig().providers["openai"], OpenAIConfig) + def test_aiconfig_providers_google_is_instance(self): + self.assertIsInstance(AIConfig().providers["google"], GoogleConfig) -def test_openai_config_url_default(): - config = OpenAIConfig() - assert config.url == "https://api.openai.com/v1" + def test_anthropic_config_driver_is_anthropic(self): + self.assertEqual(AnthropicConfig().driver, "anthropic") + def test_anthropic_config_picks_up_api_key_from_env(self): + self._patch_env({"ANTHROPIC_API_KEY": "test-anthropic-key-123"}) + self.assertEqual(AnthropicConfig().key, "test-anthropic-key-123") -def test_openai_config_url_can_be_overridden(monkeypatch): - monkeypatch.setenv("OPENAI_BASE_URL", "https://openai-proxy.example.com/v1") - config = OpenAIConfig() - assert config.url == "https://openai-proxy.example.com/v1" + def test_anthropic_config_key_defaults_to_empty_when_env_not_set(self): + self._patch_env(unset=["ANTHROPIC_API_KEY"]) + self.assertEqual(AnthropicConfig().key, "") + def test_anthropic_config_url_default(self): + self.assertEqual(AnthropicConfig().url, "https://api.anthropic.com") -# ─── GoogleConfig ───────────────────────────────────────────────────────────── + def test_anthropic_config_url_can_be_overridden(self): + self._patch_env({"ANTHROPIC_BASE_URL": "https://my-proxy.example.com"}) + self.assertEqual(AnthropicConfig().url, "https://my-proxy.example.com") + def test_openai_config_driver_is_openai(self): + self.assertEqual(OpenAIConfig().driver, "openai") -def test_google_config_driver_is_google(): - config = GoogleConfig() - assert config.driver == "google" + def test_openai_config_picks_up_api_key_from_env(self): + self._patch_env({"OPENAI_API_KEY": "sk-openai-test-key"}) + self.assertEqual(OpenAIConfig().key, "sk-openai-test-key") + def test_openai_config_key_defaults_to_empty_when_env_not_set(self): + self._patch_env(unset=["OPENAI_API_KEY"]) + self.assertEqual(OpenAIConfig().key, "") -def test_google_config_picks_up_gemini_api_key(monkeypatch): - monkeypatch.setenv("GEMINI_API_KEY", "gemini-key-abc") - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - config = GoogleConfig() - assert config.key == "gemini-key-abc" + def test_openai_config_url_default(self): + self.assertEqual(OpenAIConfig().url, "https://api.openai.com/v1") + def test_openai_config_url_can_be_overridden(self): + self._patch_env({"OPENAI_BASE_URL": "https://openai-proxy.example.com/v1"}) + self.assertEqual(OpenAIConfig().url, "https://openai-proxy.example.com/v1") -def test_google_config_falls_back_to_google_api_key(monkeypatch): - """When GEMINI_API_KEY is not set, fall back to GOOGLE_API_KEY.""" - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - monkeypatch.setenv("GOOGLE_API_KEY", "google-api-fallback") - config = GoogleConfig() - assert config.key == "google-api-fallback" + def test_google_config_driver_is_google(self): + self.assertEqual(GoogleConfig().driver, "google") + def test_google_config_picks_up_gemini_api_key(self): + self._patch_env({"GEMINI_API_KEY": "gemini-key-abc"}, unset=["GOOGLE_API_KEY"]) + self.assertEqual(GoogleConfig().key, "gemini-key-abc") -def test_google_config_gemini_key_takes_precedence(monkeypatch): - """GEMINI_API_KEY wins over GOOGLE_API_KEY when both are set.""" - monkeypatch.setenv("GEMINI_API_KEY", "gemini-wins") - monkeypatch.setenv("GOOGLE_API_KEY", "google-loses") - config = GoogleConfig() - assert config.key == "gemini-wins" + def test_google_config_falls_back_to_google_api_key(self): + self._patch_env({"GOOGLE_API_KEY": "google-api-fallback"}, unset=["GEMINI_API_KEY"]) + self.assertEqual(GoogleConfig().key, "google-api-fallback") + def test_google_config_gemini_key_takes_precedence(self): + self._patch_env({"GEMINI_API_KEY": "gemini-wins", "GOOGLE_API_KEY": "google-loses"}) + self.assertEqual(GoogleConfig().key, "gemini-wins") -def test_google_config_key_defaults_to_empty_when_neither_set(monkeypatch): - monkeypatch.delenv("GEMINI_API_KEY", raising=False) - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - config = GoogleConfig() - assert config.key == "" + def test_google_config_key_defaults_to_empty_when_neither_set(self): + self._patch_env(unset=["GEMINI_API_KEY", "GOOGLE_API_KEY"]) + self.assertEqual(GoogleConfig().key, "") diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py index bf3ca7b5..59058b45 100644 --- a/fastapi_startkit/tests/ai/test_lab.py +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -1,51 +1,42 @@ """Lab resolves the active provider, model, and LangChain model URL from config.""" -import pytest +import unittest from fastapi_startkit.ai import AIConfig from fastapi_startkit.ai.lab import Lab, ModelType from fastapi_startkit.application import app -@pytest.fixture -def ai_config(): - container = app() - container.bind("ai", AIConfig()) - container.make("config").set("ai", AIConfig()) - return container +class TestLab(unittest.TestCase): + def setUp(self): + container = app() + container.bind("ai", AIConfig()) + container.make("config").set("ai", AIConfig()) + def test_provider_key_maps_google_to_genai(self): + self.assertEqual(Lab.GOOGLE.get_provider_key(), "google_genai") + self.assertEqual(Lab.OPENAI.get_provider_key(), "openai") + self.assertEqual(Lab.ANTHROPIC.get_provider_key(), "anthropic") + self.assertEqual(Lab.ELEVENLABS.get_provider_key(), "elevenlabs") -def test_provider_key_maps_google_to_genai(): - assert Lab.GOOGLE.get_provider_key() == "google_genai" - assert Lab.OPENAI.get_provider_key() == "openai" - assert Lab.ANTHROPIC.get_provider_key() == "anthropic" - assert Lab.ELEVENLABS.get_provider_key() == "elevenlabs" + def test_get_model_returns_explicit_override(self): + self.assertEqual(Lab.GOOGLE.get_model("custom-model"), "custom-model") + def test_get_model_text_default(self): + self.assertEqual(Lab.GOOGLE.get_model(), "gemini-2.0-flash") + self.assertEqual(Lab.ANTHROPIC.get_model(), "claude-sonnet-4-6") + self.assertEqual(Lab.OPENAI.get_model(), "gpt-4o") -def test_get_model_returns_explicit_override(): - # No config needed — an explicit model short-circuits the lookup. - assert Lab.GOOGLE.get_model("custom-model") == "custom-model" + def test_get_model_image_default(self): + self.assertEqual(Lab.OPENAI.get_model(model_type=ModelType.IMAGE), "dall-e-3") + self.assertEqual(Lab.GOOGLE.get_model(model_type=ModelType.IMAGE), "imagen-3.0-generate-002") + def test_get_provider_uses_config_default(self): + self.assertEqual(Lab.get_provider().value, "google") -def test_get_model_text_default(ai_config): - assert Lab.GOOGLE.get_model() == "gemini-2.0-flash" - assert Lab.ANTHROPIC.get_model() == "claude-sonnet-4-6" - assert Lab.OPENAI.get_model() == "gpt-4o" + def test_get_provider_explicit_wins(self): + self.assertIs(Lab.get_provider("anthropic"), Lab.ANTHROPIC) - -def test_get_model_image_default(ai_config): - assert Lab.OPENAI.get_model(model_type=ModelType.IMAGE) == "dall-e-3" - assert Lab.GOOGLE.get_model(model_type=ModelType.IMAGE) == "imagen-3.0-generate-002" - - -def test_get_provider_uses_config_default(ai_config): - assert Lab.get_provider().value == "google" - - -def test_get_provider_explicit_wins(): - assert Lab.get_provider("anthropic") is Lab.ANTHROPIC - - -def test_get_model_url_text(ai_config): - assert Lab.get_model_url() == "google_genai:gemini-2.0-flash" - assert Lab.get_model_url("anthropic") == "anthropic:claude-sonnet-4-6" + def test_get_model_url_text(self): + self.assertEqual(Lab.get_model_url(), "google_genai:gemini-2.0-flash") + self.assertEqual(Lab.get_model_url("anthropic"), "anthropic:claude-sonnet-4-6") diff --git a/fastapi_startkit/tests/ai/test_provider.py b/fastapi_startkit/tests/ai/test_provider.py index 0bad8fe2..1fba50a0 100644 --- a/fastapi_startkit/tests/ai/test_provider.py +++ b/fastapi_startkit/tests/ai/test_provider.py @@ -1,5 +1,6 @@ """Tests for AIProvider service provider.""" +import unittest from unittest.mock import MagicMock from fastapi_startkit.ai import AIConfig @@ -7,112 +8,85 @@ from fastapi_startkit.providers import Provider -# ─── AIProvider class contract ──────────────────────────────────────────────── +class TestAIProvider(unittest.TestCase): + def test_ai_provider_is_a_provider(self): + self.assertTrue(issubclass(AIProvider, Provider)) + def test_ai_provider_key(self): + self.assertEqual(AIProvider.provider_key, "ai") -def test_ai_provider_is_a_provider(): - assert issubclass(AIProvider, Provider) + def test_register_binds_ai_config_to_container(self): + """register() must call app.bind('ai', ).""" + fake_app = MagicMock() + provider = AIProvider(fake_app) + provider.register() -def test_ai_provider_key(): - assert AIProvider.provider_key == "ai" + fake_app.bind.assert_called_once() + call_args = fake_app.bind.call_args + self.assertEqual(call_args[0][0], "ai") + self.assertIsInstance(call_args[0][1], AIConfig) + def test_register_does_not_raise(self): + fake_app = MagicMock() + provider = AIProvider(fake_app) -# ─── AIProvider.register() ──────────────────────────────────────────────────── + provider.register() + def test_boot_sets_ai_in_config_store(self): + """boot() must call config.set('ai', ) so Config.get('ai') works.""" + ai_config_instance = AIConfig() -def test_register_binds_ai_config_to_container(): - """register() must call app.bind('ai', ).""" - fake_app = MagicMock() - provider = AIProvider(fake_app) + fake_config_store = MagicMock() + fake_app = MagicMock() + fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store - provider.register() + provider = AIProvider(fake_app) + provider.boot() - fake_app.bind.assert_called_once() - call_args = fake_app.bind.call_args - assert call_args[0][0] == "ai" - assert isinstance(call_args[0][1], AIConfig) + fake_config_store.set.assert_called_once_with("ai", ai_config_instance) + def test_boot_does_not_raise(self): + ai_config_instance = AIConfig() + fake_config_store = MagicMock() + fake_app = MagicMock() + fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store -def test_register_does_not_raise(): - fake_app = MagicMock() - provider = AIProvider(fake_app) + provider = AIProvider(fake_app) + provider.boot() - # Should not raise any exception - provider.register() + def test_config_get_ai_returns_ai_config_data_after_provider_boots(self): + """Full integration: after AIProvider boots, Config.get('ai') exposes AI config data. + The framework's Configuration.set() serialises dataclasses to a dotty-dict, so + Config.get('ai') returns a mapping rather than an AIConfig instance. The test + verifies the 'default' key is present and the raw container binding stays typed. + """ + from fastapi_startkit.application import Application + from fastapi_startkit.configuration.config import Config -# ─── AIProvider.boot() ──────────────────────────────────────────────────────── + app = Application() + ai_config_instance = AIConfig() -def test_boot_sets_ai_in_config_store(): - """boot() must call config.set('ai', ) so Config.get('ai') works.""" - ai_config_instance = AIConfig() + app.bind("ai", ai_config_instance) + app.make("config").set("ai", ai_config_instance) - fake_config_store = MagicMock() - fake_app = MagicMock() - fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store + result = Config.get("ai") + self.assertIsNotNone(result) + self.assertEqual(result["default"], ai_config_instance.default) - provider = AIProvider(fake_app) - provider.boot() + self.assertIsInstance(app.make("ai"), AIConfig) - # Verify config store received the AIConfig under the 'ai' key - fake_config_store.set.assert_called_once_with("ai", ai_config_instance) + def test_ai_provider_register_and_boot_together(self): + """register() followed by boot() produces an AIConfig in the container.""" + from fastapi_startkit.application import Application + app = Application() -def test_boot_does_not_raise(): - ai_config_instance = AIConfig() - fake_config_store = MagicMock() - fake_app = MagicMock() - fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store + provider = AIProvider(app) + provider.register() + provider.boot() - provider = AIProvider(fake_app) - provider.boot() # must not raise - - -# ─── Integration: Config.get('ai') after boot ───────────────────────────────── - - -def test_config_get_ai_returns_ai_config_data_after_provider_boots(): - """Full integration: after AIProvider boots, Config.get('ai') exposes AI config data. - - The framework's Configuration.set() serialises dataclasses to a dotty-dict, so - Config.get('ai') returns a mapping rather than an AIConfig instance. The test - verifies the 'default' key is present and the raw container binding stays typed. - """ - from fastapi_startkit.application import Application - from fastapi_startkit.configuration.config import Config - - # Use the test Application singleton (initialised by the session fixture) - app = Application() - - ai_config_instance = AIConfig() - - # Simulate what AIProvider.register() does - app.bind("ai", ai_config_instance) - - # Simulate what AIProvider.boot() does — config.set() serialises the dataclass - app.make("config").set("ai", ai_config_instance) - - # Config.get('ai') returns the serialised dict structure - result = Config.get("ai") - assert result is not None - # The 'default' field must survive serialisation - assert result["default"] == ai_config_instance.default - - # The raw container binding retains the typed AIConfig instance - assert isinstance(app.make("ai"), AIConfig) - - -def test_ai_provider_register_and_boot_together(): - """register() followed by boot() produces an AIConfig in the container.""" - from fastapi_startkit.application import Application - - app = Application() - - provider = AIProvider(app) - provider.register() - provider.boot() - - ai_value = app.make("ai") - assert isinstance(ai_value, AIConfig) + ai_value = app.make("ai") + self.assertIsInstance(ai_value, AIConfig) From 2f91623d9950009f51a2f2c077e618eaf107dcdb Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 21:51:32 -0700 Subject: [PATCH 08/31] chore: ignore node_modules Co-Authored-By: Claude Opus 4.8 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5a93aab3..2cd7994e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea .DS_Store **/.venv +**/node_modules/ **/__pycache__/ **/*.db **/*.sqlite3 From a08a4bf8f5d747aefe3a9721c4fd7180b6a77a4c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 22:08:36 -0700 Subject: [PATCH 09/31] style(ai): drop explanatory comments from source Co-Authored-By: Claude Opus 4.8 --- .../src/fastapi_startkit/ai/audio.py | 11 ---------- .../src/fastapi_startkit/ai/audio_factory.py | 20 +++++++------------ .../src/fastapi_startkit/ai/document.py | 10 ---------- .../src/fastapi_startkit/ai/image.py | 11 ---------- .../src/fastapi_startkit/ai/image_factory.py | 1 - 5 files changed, 7 insertions(+), 46 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio.py b/fastapi_startkit/src/fastapi_startkit/ai/audio.py index a05c9bc7..138cbd15 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio.py @@ -46,8 +46,6 @@ def data(self) -> bytes: def _auto_filename(self) -> str: return f"{uuid.uuid4()}.{self._fmt}" - # ── Storage helpers ──────────────────────────────────────────────────────── - async def store(self) -> str: """Save to the default private disk with an auto-generated filename.""" return await self._save(self._auto_filename(), disk="local") @@ -64,8 +62,6 @@ async def storePubliclyAs(self, name: str) -> str: """Save to the public disk with a custom filename.""" return await self._save(name, disk="public") - # ── Internal ─────────────────────────────────────────────────────────────── - async def _save(self, name: str, disk: str = "local") -> str: return await asyncio.to_thread(self._save_sync, name, disk) @@ -102,7 +98,6 @@ class Audio: Available OpenAI TTS voices: alloy, echo, fable, onyx, nova, shimmer. """ - # OpenAI TTS voice presets _DEFAULT_VOICE = "alloy" _DEFAULT_FEMALE_VOICE = "nova" _DEFAULT_MALE_VOICE = "onyx" @@ -119,8 +114,6 @@ def of(cls, text: str) -> "Audio": """Create an :class:`Audio` builder with the given input text.""" return cls(text) - # ── Modifier methods (chainable) ─────────────────────────────────────────── - def female(self) -> "Audio": """Use a female voice (``nova``).""" self._voice = self._DEFAULT_FEMALE_VOICE @@ -158,8 +151,6 @@ def format(self, fmt: str) -> "Audio": self._response_format = fmt return self - # ── Generation ───────────────────────────────────────────────────────────── - async def generate(self) -> AudioResponse: """Call the configured TTS provider and return an :class:`AudioResponse`.""" provider = self._resolve_provider() @@ -172,8 +163,6 @@ async def generate(self) -> AudioResponse: ) return AudioResponse(data=data, fmt=self._response_format) - # ── Internal ─────────────────────────────────────────────────────────────── - def _resolve_provider(self) -> "AudioFactory": from .audio_factory import ( # noqa: PLC0415 ElevenLabsAudioFactory, diff --git a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py index 3613e5f3..cecfd54e 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/audio_factory.py @@ -100,7 +100,6 @@ class GoogleAudioFactory(AudioFactory): because Gemini TTS yields raw PCM16 which is wrapped in a WAV container. """ - # Map OpenAI-style voice aliases → Google Gemini voice names _VOICE_MAP: dict[str, str] = { "nova": "Aoede", "alloy": "Kore", @@ -149,7 +148,6 @@ async def synthesize( ), ) - # Gemini TTS returns raw PCM16 samples — wrap in WAV container pcm_data = response.candidates[0].content.parts[0].inline_data.data return _pcm_to_wav(pcm_data) @@ -187,14 +185,13 @@ class ElevenLabsAudioFactory(AudioFactory): not currently supported by ElevenLabs. """ - # Map OpenAI-style aliases → ElevenLabs voice IDs _VOICE_MAP: dict[str, str] = { - "nova": "21m00Tcm4TlvDq8ikWAM", # Rachel — female - "alloy": "EXAVITQu4vr4xnSDxMaL", # Bella — female - "shimmer": "MF3mGyEYCl7XYWbV9V6O", # Elli — female - "onyx": "pNInz6obpgDQGcFmaJgB", # Adam — male - "echo": "ErXwobaYiN019PkySvjV", # Antoni — male - "fable": "VR6AewLTigWG4xSOukaG", # Arnold — male + "nova": "21m00Tcm4TlvDq8ikWAM", + "alloy": "EXAVITQu4vr4xnSDxMaL", + "shimmer": "MF3mGyEYCl7XYWbV9V6O", + "onyx": "pNInz6obpgDQGcFmaJgB", + "echo": "ErXwobaYiN019PkySvjV", + "fable": "VR6AewLTigWG4xSOukaG", } def __init__(self, api_key: str | None = None): @@ -223,9 +220,6 @@ async def synthesize( return b"".join(audio_chunks) -# ─── PCM → WAV helper ───────────────────────────────────────────────────────── - - def _pcm_to_wav( pcm_data: bytes, sample_rate: int = 24000, @@ -248,7 +242,7 @@ def _pcm_to_wav( b"WAVE", b"fmt ", 16, - 1, # PCM + 1, channels, sample_rate, byte_rate, diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py index 574a5876..20014faa 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/document.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py @@ -5,7 +5,6 @@ import asyncio import base64 -# Optional runtime dependency — imported at module level so tests can patch it. try: from fastapi_startkit.storage.storage import Storage except Exception: # pragma: no cover @@ -34,8 +33,6 @@ def __init__(self, content: str | bytes, name: str = "", media_type: str = "text self.name = name self.media_type = media_type - # ── Sync constructors (text) ─────────────────────────────────────────────── - @classmethod def from_path(cls, path: str) -> "Document": """Load a document from a local file path. @@ -51,8 +48,6 @@ def from_path(cls, path: str) -> "Document": content = f.read() return cls(content=content, name=path) - # ── Async constructors (binary) ──────────────────────────────────────────── - @classmethod async def from_storage(cls, key: str) -> "Document": """Load a binary file from application storage (``storage/``) asynchronously. @@ -65,7 +60,6 @@ def _read() -> bytes: if Storage is not None: try: disk = Storage.disk("local") - # Resolve the full path and read as binary resolved_path = disk.get_path(key) with open(resolved_path, "rb") as f: return f.read() @@ -95,8 +89,6 @@ async def from_url(cls, url: str) -> "Document": name = url.rstrip("/").split("/")[-1] return cls(content=response.content, name=name) - # ── Binary accessor ──────────────────────────────────────────────────────── - def to_bytes(self) -> bytes: """Return the document content as raw bytes. @@ -117,8 +109,6 @@ def to_base64(self) -> str: """ return base64.b64encode(self.to_bytes()).decode("utf-8") - # ── LLM content blocks ───────────────────────────────────────────────────── - def to_anthropic_block(self) -> dict: """Return an Anthropic-compatible content block for this document.""" return { diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image.py b/fastapi_startkit/src/fastapi_startkit/ai/image.py index ec11e28b..0009cb21 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image.py @@ -47,8 +47,6 @@ def data(self) -> bytes: def _auto_filename(self) -> str: return f"{uuid.uuid4()}.{self._fmt}" - # ── Storage helpers ──────────────────────────────────────────────────────── - async def store(self) -> str: """Save to the default private disk with an auto-generated filename.""" return await self._save(self._auto_filename(), disk="local") @@ -65,8 +63,6 @@ async def storePubliclyAs(self, name: str) -> str: """Save to the public disk with a custom filename.""" return await self._save(name, disk="public") - # ── Internal ─────────────────────────────────────────────────────────────── - async def _save(self, name: str, disk: str = "local") -> str: return await asyncio.to_thread(self._save_sync, name, disk) @@ -109,7 +105,6 @@ class Image: ) """ - # DALL-E 3 size presets _LANDSCAPE_SIZE = "1792x1024" _PORTRAIT_SIZE = "1024x1792" _SQUARE_SIZE = "1024x1024" @@ -126,8 +121,6 @@ def of(cls, prompt: str) -> "Image": """Create an :class:`Image` builder with the given prompt.""" return cls(prompt) - # ── Modifier methods (chainable) ─────────────────────────────────────────── - def attachments(self, docs: list) -> "Image": """Attach :class:`~fastapi_startkit.ai.Document` objects for an editing request.""" self._attachments = list(docs) @@ -158,8 +151,6 @@ def quality(self, q: str) -> "Image": self._quality = q return self - # ── Generation ───────────────────────────────────────────────────────────── - async def generate(self) -> ImageResponse: """Call the configured image provider and return an :class:`ImageResponse`.""" provider = self._resolve_provider() @@ -180,8 +171,6 @@ async def generate(self) -> ImageResponse: return ImageResponse(data=image_bytes, fmt="png") - # ── Internal ─────────────────────────────────────────────────────────────── - def _resolve_provider(self) -> "ImageFactory": from .image_factory import ( # noqa: PLC0415 GoogleImageFactory, diff --git a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py index b5bf4ba4..eb92ddb6 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/image_factory.py @@ -105,7 +105,6 @@ class GoogleImageFactory(ImageFactory): +--------------+-----------+ """ - # Map DALL-E-style pixel sizes to Imagen aspect ratios _ASPECT_MAP: dict[str, str] = { "1024x1024": "1:1", "1792x1024": "16:9", From 99f0b0f3cd71ce5a5f932a1678fc1eb8f4cf9e91 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 22:08:36 -0700 Subject: [PATCH 10/31] refactor(ai): merge AIConfig via config store, drop unused _memory_backend AIProvider.register() now resolves AIConfig and merges it into the config store (merge_config_from) instead of binding into the container and setting it in boot(); boot() is a no-op. Drop the unused _memory_backend class attribute on Agent. Update provider tests to the new behaviour. Co-Authored-By: Claude Opus 4.8 --- .../src/fastapi_startkit/ai/agent.py | 1 - .../ai/providers/ai_provider.py | 18 +---- fastapi_startkit/tests/ai/test_provider.py | 67 +++++-------------- 3 files changed, 20 insertions(+), 66 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index dfdef4a6..3226429d 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -17,7 +17,6 @@ class Agent: _max_tokens: int = 4096 _timeout: float = 30.0 _top_p: float = 1.0 - _memory_backend: str = "" def __init__(self): self._fakes: dict[str, AgentResponse | AgentSnapshot] = {} diff --git a/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py index a396043a..2eee207f 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py @@ -6,25 +6,13 @@ class AIProvider(Provider): - """Service provider that bootstraps the AI module. - - Registers :class:`~fastapi_startkit.ai.config.AIConfig` under the ``ai`` - key in the application container so it is accessible via ``Config.get('ai')``. - - Register it in your application:: - - app = Application(providers=[AIProvider]) - """ - provider_key = "ai" def register(self) -> None: - """Bind AIConfig into the container under the 'ai' key.""" from fastapi_startkit.ai import AIConfig - self.app.bind("ai", AIConfig()) + config = self.resolve_config(AIConfig) + self.merge_config_from(config, self.provider_key) def boot(self) -> None: - """Merge AI config into the shared Config store.""" - ai_config = self.app.make("ai") - self.app.make("config").set("ai", ai_config) + pass diff --git a/fastapi_startkit/tests/ai/test_provider.py b/fastapi_startkit/tests/ai/test_provider.py index 1fba50a0..3f050763 100644 --- a/fastapi_startkit/tests/ai/test_provider.py +++ b/fastapi_startkit/tests/ai/test_provider.py @@ -15,17 +15,20 @@ def test_ai_provider_is_a_provider(self): def test_ai_provider_key(self): self.assertEqual(AIProvider.provider_key, "ai") - def test_register_binds_ai_config_to_container(self): - """register() must call app.bind('ai', ).""" + def test_register_merges_ai_config_into_config_store(self): + """register() merges the resolved AIConfig into the config store under 'ai'.""" + fake_config = MagicMock() fake_app = MagicMock() - provider = AIProvider(fake_app) + fake_app.make.return_value = fake_config + provider = AIProvider(fake_app) provider.register() - fake_app.bind.assert_called_once() - call_args = fake_app.bind.call_args - self.assertEqual(call_args[0][0], "ai") - self.assertIsInstance(call_args[0][1], AIConfig) + fake_app.make.assert_called_with("config") + fake_config.merge_with.assert_called_once() + path, source = fake_config.merge_with.call_args[0] + self.assertEqual(path, "ai") + self.assertEqual(source["default"], AIConfig().default) def test_register_does_not_raise(self): fake_app = MagicMock() @@ -33,60 +36,24 @@ def test_register_does_not_raise(self): provider.register() - def test_boot_sets_ai_in_config_store(self): - """boot() must call config.set('ai', ) so Config.get('ai') works.""" - ai_config_instance = AIConfig() - - fake_config_store = MagicMock() - fake_app = MagicMock() - fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store - - provider = AIProvider(fake_app) - provider.boot() - - fake_config_store.set.assert_called_once_with("ai", ai_config_instance) - def test_boot_does_not_raise(self): - ai_config_instance = AIConfig() - fake_config_store = MagicMock() fake_app = MagicMock() - fake_app.make.side_effect = lambda key: ai_config_instance if key == "ai" else fake_config_store - provider = AIProvider(fake_app) - provider.boot() - def test_config_get_ai_returns_ai_config_data_after_provider_boots(self): - """Full integration: after AIProvider boots, Config.get('ai') exposes AI config data. + provider.boot() - The framework's Configuration.set() serialises dataclasses to a dotty-dict, so - Config.get('ai') returns a mapping rather than an AIConfig instance. The test - verifies the 'default' key is present and the raw container binding stays typed. - """ + def test_register_and_boot_expose_ai_config(self): + """Full integration: after AIProvider registers, Config.get('ai') exposes AI config data.""" from fastapi_startkit.application import Application from fastapi_startkit.configuration.config import Config app = Application() - ai_config_instance = AIConfig() - - app.bind("ai", ai_config_instance) - app.make("config").set("ai", ai_config_instance) - - result = Config.get("ai") - self.assertIsNotNone(result) - self.assertEqual(result["default"], ai_config_instance.default) - - self.assertIsInstance(app.make("ai"), AIConfig) - - def test_ai_provider_register_and_boot_together(self): - """register() followed by boot() produces an AIConfig in the container.""" - from fastapi_startkit.application import Application - - app = Application() - provider = AIProvider(app) provider.register() provider.boot() - ai_value = app.make("ai") - self.assertIsInstance(ai_value, AIConfig) + ai = Config.get("ai") + self.assertIsNotNone(ai) + self.assertEqual(ai["default"], AIConfig().default) + self.assertIn("providers", ai) From 7d4254c82292ece04ca2cf266fca523505f4b28b Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 22:08:36 -0700 Subject: [PATCH 11/31] chore(ai): default gemini model to gemini-2.5-flash-lite Co-Authored-By: Claude Opus 4.8 --- fastapi_startkit/src/fastapi_startkit/ai/config/config.py | 2 +- fastapi_startkit/tests/ai/test_agent.py | 4 ++-- fastapi_startkit/tests/ai/test_lab.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py index 5efcff47..39e2d702 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/config/config.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/config/config.py @@ -45,7 +45,7 @@ class GoogleConfig: models: dict = field( default_factory=lambda: { - "default": "gemini-2.0-flash", + "default": "gemini-2.5-flash-lite", "default_image": "imagen-3.0-generate-002", } ) diff --git a/fastapi_startkit/tests/ai/test_agent.py b/fastapi_startkit/tests/ai/test_agent.py index dd06665f..911cfa39 100644 --- a/fastapi_startkit/tests/ai/test_agent.py +++ b/fastapi_startkit/tests/ai/test_agent.py @@ -89,7 +89,7 @@ class GoogleAgent(Agent): GoogleAgent().prompt("hi") self.assertEqual(captured["provider"], "google_genai") - self.assertEqual(captured["model"], "gemini-2.0-flash") + self.assertEqual(captured["model"], "gemini-2.5-flash-lite") def test_stream_yields_tokens_from_the_model(self): self.setup_agent([AIMessage(content="streamed reply")]) @@ -99,7 +99,7 @@ def test_stream_yields_tokens_from_the_model(self): self.assertEqual("".join(chunks), "streamed reply") def test_resolve_model_falls_back_to_lab_default(self): - self.assertEqual(Agent()._resolve_model(), "gemini-2.0-flash") + self.assertEqual(Agent()._resolve_model(), "gemini-2.5-flash-lite") class AnthropicAgent(Agent): _provider = "anthropic" diff --git a/fastapi_startkit/tests/ai/test_lab.py b/fastapi_startkit/tests/ai/test_lab.py index 59058b45..644c0bac 100644 --- a/fastapi_startkit/tests/ai/test_lab.py +++ b/fastapi_startkit/tests/ai/test_lab.py @@ -23,7 +23,7 @@ def test_get_model_returns_explicit_override(self): self.assertEqual(Lab.GOOGLE.get_model("custom-model"), "custom-model") def test_get_model_text_default(self): - self.assertEqual(Lab.GOOGLE.get_model(), "gemini-2.0-flash") + self.assertEqual(Lab.GOOGLE.get_model(), "gemini-2.5-flash-lite") self.assertEqual(Lab.ANTHROPIC.get_model(), "claude-sonnet-4-6") self.assertEqual(Lab.OPENAI.get_model(), "gpt-4o") @@ -38,5 +38,5 @@ def test_get_provider_explicit_wins(self): self.assertIs(Lab.get_provider("anthropic"), Lab.ANTHROPIC) def test_get_model_url_text(self): - self.assertEqual(Lab.get_model_url(), "google_genai:gemini-2.0-flash") + self.assertEqual(Lab.get_model_url(), "google_genai:gemini-2.5-flash-lite") self.assertEqual(Lab.get_model_url("anthropic"), "anthropic:claude-sonnet-4-6") From c512f50d129b9ba53635445b89b3ab2ca6ba23e9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 22:17:45 -0700 Subject: [PATCH 12/31] refactor(ai): make Agent.prompt() and stream() async Agent.prompt() and stream() are now coroutines/async generators, matching the framework's async-first design. The Runner uses ainvoke/astream/ainvoke for tools, the fake/record stand-ins and AgentSnapshot.resolve are async, and the fake/record/agent tests run under IsolatedAsyncioTestCase. Co-Authored-By: Claude Opus 4.8 --- .../src/fastapi_startkit/ai/agent.py | 41 ++++--- .../src/fastapi_startkit/ai/document.py | 2 +- .../src/fastapi_startkit/ai/fakes.py | 2 +- .../src/fastapi_startkit/ai/response.py | 4 +- .../src/fastapi_startkit/ai/runner.py | 18 +-- .../src/fastapi_startkit/ai/testing.py | 6 +- fastapi_startkit/tests/ai/test_agent.py | 22 ++-- fastapi_startkit/tests/ai/test_agent_fake.py | 110 +++++++++--------- 8 files changed, 107 insertions(+), 98 deletions(-) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py index 3226429d..0ac1c49b 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/agent.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py @@ -1,7 +1,7 @@ from __future__ import annotations import fnmatch -from typing import Any, Callable, Iterator, Optional, Type +from typing import Any, AsyncIterator, Callable, Optional, Type from .document import Document from .lab import Lab @@ -46,7 +46,7 @@ def before(self, message: str) -> str: def after(self, response: AgentResponse) -> AgentResponse: return response - def prompt( + async def prompt( self, message: str, *, @@ -58,7 +58,7 @@ def prompt( stand_in = self._faked() if stand_in is not None: - response = stand_in.prompt(message, attachments=attachments) + response = await stand_in.prompt(message, attachments=attachments) self._log_call("prompt", message) return self.after(response) @@ -71,43 +71,45 @@ def prompt( match = self._match_fake(message) if match is not None: if isinstance(match, AgentSnapshot): - response = match.resolve(self, message, **_run_kwargs) + response = await match.resolve(self, message, **_run_kwargs) else: response = match self._log_call("prompt", message) return self.after(response) - def _call(msg: str) -> AgentResponse: - return self._run(msg, **_run_kwargs) + async def _call(msg: str) -> AgentResponse: + return await self._run(msg, **_run_kwargs) - response = self._apply_middleware(message, _call) + response = await self._apply_middleware(message, _call) self._log_call("prompt", message) return self.after(response) - def stream( + async def stream( self, message: str, *, model: str | None = None, provider_options: dict | None = None, - ) -> Iterator[str]: + ) -> AsyncIterator[str]: message = self.before(message) self._log_call("stream", message) swapped = self._faked() if swapped is not None: - yield swapped.prompt(message).content + response = await swapped.prompt(message) + yield response.content return fake = self._match_fake(message) if fake is not None: if isinstance(fake, AgentSnapshot): - response = fake.resolve(self, message) + response = await fake.resolve(self, message) else: response = fake yield response.content return - yield from self._stream(message, model=model, provider_options=provider_options) + async for chunk in self._stream(message, model=model, provider_options=provider_options): + yield chunk @classmethod def fake(cls, responses: dict | None = None) -> "AgentBinding": @@ -161,7 +163,7 @@ def _match_fake(self, message: str) -> Optional[AgentResponse | AgentSnapshot]: def _log_call(self, method: str, message: str) -> None: self._call_log.append({"method": method, "message": message}) - def _apply_middleware(self, message: str, final: Callable[[str], AgentResponse]) -> AgentResponse: + async def _apply_middleware(self, message: str, final: Callable[[str], Any]) -> AgentResponse: chain = list(self.middleware()) def build(mw_list: list, fn: Callable) -> Callable: @@ -171,7 +173,7 @@ def build(mw_list: list, fn: Callable) -> Callable: next_fn = build(tail, fn) return lambda msg: head(msg, next_fn) - return build(chain, final)(message) + return await build(chain, final)(message) def _resolve_model(self, override: str | None = None) -> str: return Lab.get_provider(self._provider).get_model(override or self._model or None) @@ -247,7 +249,7 @@ def _to_agent_response(self, result: Any) -> AgentResponse: return AgentResponse(content=content, tool_calls=tool_calls, usage=usage, raw=result) - def _run( + async def _run( self, message: str, model: str | None = None, @@ -259,18 +261,19 @@ def _run( messages = self._build_messages(message, attachments) chat_model = self._build_model(model, provider_options) - result = Runner(chat_model, self.tools(), self._max_steps).run(messages) + result = await Runner(chat_model, self.tools(), self._max_steps).run(messages) return self._to_agent_response(result) - def _stream( + async def _stream( self, message: str, model: str | None = None, provider_options: dict | None = None, - ) -> Iterator[str]: + ) -> AsyncIterator[str]: from .runner import StreamRunner # noqa: PLC0415 messages = self._build_messages(message) chat_model = self._build_model(model, provider_options) - yield from StreamRunner(chat_model, self.tools(), self._max_steps).run(messages) + async for chunk in StreamRunner(chat_model, self.tools(), self._max_steps).run(messages): + yield chunk diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py index 20014faa..ce71ad06 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/document.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py @@ -20,7 +20,7 @@ class Document: Text:: doc = Document.from_path("report.txt") - agent.prompt("Summarise this", attachments=[doc]) + await agent.prompt("Summarise this", attachments=[doc]) Binary image:: diff --git a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py index 430f02a9..15771070 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py @@ -20,7 +20,7 @@ ]) agent = JobAssistant() agent._build_model = lambda *a, **k: model - response = agent.prompt("find me a python job") + response = await agent.prompt("find me a python job") assert response.content == "Here is a Python Developer role at Shopify." """ diff --git a/fastapi_startkit/src/fastapi_startkit/ai/response.py b/fastapi_startkit/src/fastapi_startkit/ai/response.py index 77383bdb..ab05118c 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/response.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/response.py @@ -80,13 +80,13 @@ def save(self, response: AgentResponse) -> None: indent=2, ) - def resolve(self, agent: "Agent", message: str, **run_kwargs: Any) -> AgentResponse: + async def resolve(self, agent: "Agent", message: str, **run_kwargs: Any) -> AgentResponse: """ Return the response — from disk if recorded, or from the real API (which is then saved for future runs). """ if self.exists(): return self.load() - response = agent._run(message, **run_kwargs) + response = await agent._run(message, **run_kwargs) self.save(response) return response diff --git a/fastapi_startkit/src/fastapi_startkit/ai/runner.py b/fastapi_startkit/src/fastapi_startkit/ai/runner.py index 351a2176..2a979a90 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/runner.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/runner.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Iterator, Sequence +from collections.abc import AsyncIterator, Sequence from typing import Any from langchain_core.language_models.chat_models import BaseChatModel @@ -24,17 +24,17 @@ def __init__( ) self.max_steps = max_steps - def run(self, messages: Sequence[Message]) -> BaseMessage: + async def run(self, messages: Sequence[Message]) -> BaseMessage: history: list[Message] = list(messages) - response: AIMessage = self.model.invoke(history) # type: ignore[assignment] + response: AIMessage = await self.model.ainvoke(history) # type: ignore[assignment] if not response.tool_calls: return response - return self._run_tools(response.tool_calls)[-1] + return (await self._run_tools(response.tool_calls))[-1] - def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: - return [self._resolve_tool(call["name"]).invoke(call) for call in tool_calls] + async def _run_tools(self, tool_calls: list[dict[str, Any]]) -> list[BaseMessage]: + return [await self._resolve_tool(call["name"]).ainvoke(call) for call in tool_calls] def _resolve_tool(self, name: str) -> BaseTool: try: @@ -44,12 +44,12 @@ def _resolve_tool(self, name: str) -> BaseTool: class StreamRunner(Runner): - def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[override] + async def run(self, messages: Sequence[Message]) -> AsyncIterator[str]: # type: ignore[override] history: list[Message] = list(messages) for _ in range(self.max_steps): gathered: AIMessageChunk | None = None - for chunk in self.model.stream(history): + async for chunk in self.model.astream(history): if chunk.content: yield chunk.content if isinstance(chunk.content, str) else str(chunk.content) gathered = chunk if gathered is None else gathered + chunk # type: ignore[operator] @@ -57,4 +57,4 @@ def run(self, messages: Sequence[Message]) -> Iterator[str]: # type: ignore[ove if gathered is None or not gathered.tool_calls: return history.append(gathered) - history.extend(self._run_tools(gathered.tool_calls)) + history.extend(await self._run_tools(gathered.tool_calls)) diff --git a/fastapi_startkit/src/fastapi_startkit/ai/testing.py b/fastapi_startkit/src/fastapi_startkit/ai/testing.py index 85382bc4..0f61287c 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/testing.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/testing.py @@ -63,7 +63,7 @@ def __init__(self, responses: dict[str, Any] | None = None) -> None: super().__init__() self.responses = responses or {} - def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: + async def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: self._record_call(message, attachments) if not self.responses: return AgentResponse(content="") @@ -85,7 +85,7 @@ def _key(message: str, attachments: list[Document] | None) -> str: payload = json.dumps({"message": message, "attachments": names}, sort_keys=True) return hashlib.sha256(payload.encode()).hexdigest() - def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: + async def prompt(self, message: str, attachments: list[Document] | None = None) -> AgentResponse: self._record_call(message, attachments) cassette = self.cassette assert cassette is not None, "RecordingAgent has no cassette resolved" @@ -93,7 +93,7 @@ def prompt(self, message: str, attachments: list[Document] | None = None) -> Age key = self._key(message, attachments) if key in store: return AgentResponse(content=store[key]) - response = self._real._run(message, attachments=attachments) + response = await self._real._run(message, attachments=attachments) store[key] = response.content cassette.parent.mkdir(parents=True, exist_ok=True) cassette.write_text(json.dumps(store, indent=2, sort_keys=True)) diff --git a/fastapi_startkit/tests/ai/test_agent.py b/fastapi_startkit/tests/ai/test_agent.py index 911cfa39..cd84dfb9 100644 --- a/fastapi_startkit/tests/ai/test_agent.py +++ b/fastapi_startkit/tests/ai/test_agent.py @@ -24,7 +24,7 @@ def tools(self): return [search_jobs] -class TestAgent(unittest.TestCase): +class TestAgent(unittest.IsolatedAsyncioTestCase): def setUp(self): container = app() container.bind("ai", AIConfig()) @@ -37,17 +37,17 @@ def setup_agent(self, turns: list[AIMessage]): self.addCleanup(patcher.stop) return model - def test_prompt_returns_agent_response(self): + async def test_prompt_returns_agent_response(self): self.setup_agent([AIMessage(content="hello back")]) agent = Agent() - result = agent.prompt("hi there") + result = await agent.prompt("hi there") self.assertIsInstance(result, AgentResponse) self.assertEqual(result.content, "hello back") agent.assert_prompted() - def test_search_jobs_tool_returns_listing(self): + async def test_search_jobs_tool_returns_listing(self): self.setup_agent( [ AIMessage( @@ -57,21 +57,21 @@ def test_search_jobs_tool_returns_listing(self): ] ) - result = JobAssistant().prompt("find me a python job") + result = await JobAssistant().prompt("find me a python job") self.assertEqual(result.content, "Python Developer at Shopify") self.assertEqual(result.tool_calls, []) - def test_prompt_maps_usage_metadata(self): + async def test_prompt_maps_usage_metadata(self): self.setup_agent( [AIMessage(content="done", usage_metadata={"input_tokens": 11, "output_tokens": 7, "total_tokens": 18})] ) - result = Agent().prompt("anything") + result = await Agent().prompt("anything") self.assertEqual(result.usage, {"input": 11, "output": 7}) - def test_build_model_passes_langchain_provider_key(self): + async def test_build_model_passes_langchain_provider_key(self): captured = {} def fake_init(model, **kwargs): @@ -86,15 +86,15 @@ def fake_init(model, **kwargs): class GoogleAgent(Agent): _provider = "google" - GoogleAgent().prompt("hi") + await GoogleAgent().prompt("hi") self.assertEqual(captured["provider"], "google_genai") self.assertEqual(captured["model"], "gemini-2.5-flash-lite") - def test_stream_yields_tokens_from_the_model(self): + async def test_stream_yields_tokens_from_the_model(self): self.setup_agent([AIMessage(content="streamed reply")]) - chunks = list(Agent().stream("hello")) + chunks = [chunk async for chunk in Agent().stream("hello")] self.assertEqual("".join(chunks), "streamed reply") diff --git a/fastapi_startkit/tests/ai/test_agent_fake.py b/fastapi_startkit/tests/ai/test_agent_fake.py index 09bd685a..7170096c 100644 --- a/fastapi_startkit/tests/ai/test_agent_fake.py +++ b/fastapi_startkit/tests/ai/test_agent_fake.py @@ -20,66 +20,66 @@ class SimpleAgent(Agent): pass -class TestAgentFake(unittest.TestCase): - def test_fake_with_agent_response_returns_it(self): +class TestAgentFake(unittest.IsolatedAsyncioTestCase): + async def test_fake_with_agent_response_returns_it(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="Hello world!")}): - result = agent.prompt("anything") + result = await agent.prompt("anything") self.assertEqual(result.content, "Hello world!") - def test_fake_does_not_call_provider_run(self): + async def test_fake_does_not_call_provider_run(self): agent = SimpleAgent() called = [] original_run = agent._run - def patched_run(*args, **kwargs): + async def patched_run(*args, **kwargs): called.append(True) - return original_run(*args, **kwargs) + return await original_run(*args, **kwargs) agent._run = patched_run with SimpleAgent.fake({"*": AgentResponse(content="faked")}): - agent.prompt("hello") + await agent.prompt("hello") self.assertEqual(called, [], "_run() must not be called when a fake matches") - def test_fake_with_exact_pattern(self): + async def test_fake_with_exact_pattern(self): agent = SimpleAgent() with SimpleAgent.fake({"hello": AgentResponse(content="matched hello")}): - result = agent.prompt("hello") + result = await agent.prompt("hello") self.assertEqual(result.content, "matched hello") - def test_fake_glob_hello_wildcard(self): + async def test_fake_glob_hello_wildcard(self): agent = SimpleAgent() with SimpleAgent.fake({"*hello*": AgentResponse(content="hi there")}): - result = agent.prompt("say hello to me") + result = await agent.prompt("say hello to me") self.assertEqual(result.content, "hi there") - def test_fake_glob_analyze_wildcard(self): + async def test_fake_glob_analyze_wildcard(self): agent = SimpleAgent() with SimpleAgent.fake({"*analyze*": AgentResponse(content="analysis done")}): - result = agent.prompt("please analyze this report") + result = await agent.prompt("please analyze this report") self.assertEqual(result.content, "analysis done") - def test_fake_no_match_raises(self): + async def test_fake_no_match_raises(self): agent = SimpleAgent() with SimpleAgent.fake({"*hello*": AgentResponse(content="hi")}): with self.assertRaises(Exception): - agent.prompt("goodbye") + await agent.prompt("goodbye") - def test_fake_glob_case_insensitive(self): + async def test_fake_glob_case_insensitive(self): agent = SimpleAgent() with SimpleAgent.fake({"*HELLO*": AgentResponse(content="case insensitive")}): - result = agent.prompt("say hello please") + result = await agent.prompt("say hello please") self.assertEqual(result.content, "case insensitive") - def test_fake_first_matching_pattern_wins(self): + async def test_fake_first_matching_pattern_wins(self): agent = SimpleAgent() with SimpleAgent.fake( { @@ -87,27 +87,27 @@ def test_fake_first_matching_pattern_wins(self): "*hello world*": AgentResponse(content="second match"), } ): - result = agent.prompt("hello world") + result = await agent.prompt("hello world") self.assertEqual(result.content, "first match") - def test_assert_prompted_passes_after_one_call(self): + async def test_assert_prompted_passes_after_one_call(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("first") + await agent.prompt("first") agent.assert_prompted() - def test_assert_prompted_times_2_passes_after_exactly_2_calls(self): + async def test_assert_prompted_times_2_passes_after_exactly_2_calls(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("first") - agent.prompt("second") + await agent.prompt("first") + await agent.prompt("second") agent.assert_prompted(times=2) - def test_assert_prompted_times_fails_when_count_mismatch(self): + async def test_assert_prompted_times_fails_when_count_mismatch(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("only once") + await agent.prompt("only once") with self.assertRaises(AssertionError): agent.assert_prompted(times=2) @@ -126,18 +126,18 @@ def test_assert_not_prompted_passes_when_no_calls_made(self): agent = SimpleAgent() agent.assert_not_prompted() - def test_assert_not_prompted_fails_after_one_call(self): + async def test_assert_not_prompted_fails_after_one_call(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("a prompt") + await agent.prompt("a prompt") with self.assertRaises(AssertionError): agent.assert_not_prompted() - def test_reset_clears_call_log(self): + async def test_reset_clears_call_log(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("first") + await agent.prompt("first") self.assertEqual(len(agent._call_log), 1) agent.reset() @@ -148,37 +148,37 @@ def test_reset_returns_agent_for_chaining(self): result = agent.reset() self.assertIs(result, agent) - def test_assert_not_prompted_passes_after_reset(self): + async def test_assert_not_prompted_passes_after_reset(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="ok")}): - agent.prompt("call before reset") + await agent.prompt("call before reset") agent.reset() agent.assert_not_prompted() - def test_fake_rebinding_overrides_previous(self): + async def test_fake_rebinding_overrides_previous(self): agent = SimpleAgent() with SimpleAgent.fake({"*": AgentResponse(content="first fake")}): - self.assertEqual(agent.prompt("call").content, "first fake") + self.assertEqual((await agent.prompt("call")).content, "first fake") with SimpleAgent.fake({"*": AgentResponse(content="second fake")}): - self.assertEqual(agent.prompt("call again").content, "second fake") + self.assertEqual((await agent.prompt("call again")).content, "second fake") - def test_stream_returns_fake_response(self): + async def test_stream_returns_fake_response(self): agent = SimpleAgent() with SimpleAgent.fake({"*hello*": AgentResponse(content="Faked stream!")}): - chunks = list(agent.stream("hello world")) + chunks = [chunk async for chunk in agent.stream("hello world")] self.assertEqual(chunks, ["Faked stream!"]) agent.assert_prompted(times=1) -class TestAgentRecord(unittest.TestCase): +class TestAgentRecord(unittest.IsolatedAsyncioTestCase): def setup_agent(self, content): calls = [] - def fake_run(agent_self, message, **kwargs): + async def fake_run(agent_self, message, **kwargs): calls.append(message) return AgentResponse(content=content) @@ -187,12 +187,12 @@ def fake_run(agent_self, message, **kwargs): self.addCleanup(patcher.stop) return calls - def test_first_run_records_response_to_cassette(self): + async def test_first_run_records_response_to_cassette(self): calls = self.setup_agent("recorded reply") with tempfile.TemporaryDirectory() as tmp: cassette = os.path.join(tmp, "c.json") with SimpleAgent.record(cassette): - result = SimpleAgent().prompt("hello") + result = await SimpleAgent().prompt("hello") self.assertEqual(result.content, "recorded reply") self.assertEqual(calls, ["hello"]) @@ -200,37 +200,43 @@ def test_first_run_records_response_to_cassette(self): with open(cassette) as f: self.assertIn("recorded reply", json.load(f).values()) - def test_second_run_replays_without_calling_run(self): + async def test_second_run_replays_without_calling_run(self): calls = self.setup_agent("recorded reply") with tempfile.TemporaryDirectory() as tmp: cassette = os.path.join(tmp, "c.json") with SimpleAgent.record(cassette): - SimpleAgent().prompt("hello") + await SimpleAgent().prompt("hello") with SimpleAgent.record(cassette): - replayed = SimpleAgent().prompt("hello") + replayed = await SimpleAgent().prompt("hello") self.assertEqual(replayed.content, "recorded reply") self.assertEqual(calls, ["hello"]) - def test_replay_prefers_cassette_over_live_response(self): + async def test_replay_prefers_cassette_over_live_response(self): + async def first_run(s, m, **k): + return AgentResponse(content="from first record") + + async def changed_run(s, m, **k): + return AgentResponse(content="changed live value") + with tempfile.TemporaryDirectory() as tmp: cassette = os.path.join(tmp, "c.json") - with mock.patch.object(SimpleAgent, "_run", lambda s, m, **k: AgentResponse(content="from first record")): + with mock.patch.object(SimpleAgent, "_run", first_run): with SimpleAgent.record(cassette): - SimpleAgent().prompt("hello") - with mock.patch.object(SimpleAgent, "_run", lambda s, m, **k: AgentResponse(content="changed live value")): + await SimpleAgent().prompt("hello") + with mock.patch.object(SimpleAgent, "_run", changed_run): with SimpleAgent.record(cassette): - result = SimpleAgent().prompt("hello") + result = await SimpleAgent().prompt("hello") self.assertEqual(result.content, "from first record") - def test_distinct_messages_are_recorded_separately(self): + async def test_distinct_messages_are_recorded_separately(self): calls = self.setup_agent("reply") with tempfile.TemporaryDirectory() as tmp: cassette = os.path.join(tmp, "c.json") with SimpleAgent.record(cassette): - SimpleAgent().prompt("hello") - SimpleAgent().prompt("goodbye") + await SimpleAgent().prompt("hello") + await SimpleAgent().prompt("goodbye") self.assertEqual(calls, ["hello", "goodbye"]) with open(cassette) as f: From b4b5242935712ccae011d9820006eb8c81cfe31e Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 24 Jun 2026 22:21:53 -0700 Subject: [PATCH 13/31] chore(ai): slim the [ai] extra to langchain + langchain-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the bundled provider SDKs (anthropic, openai, google-generativeai) and the unused langgraph from the [ai] extra and dev group — providers are pulled lazily by init_chat_model and are now opt-in. Fix stale langgraph references in the fake-model helper to point at the [ai] extra. Co-Authored-By: Claude Opus 4.8 --- fastapi_startkit/pyproject.toml | 5 ----- fastapi_startkit/src/fastapi_startkit/ai/fakes.py | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 36c98c30..2fa260a5 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -48,12 +48,8 @@ inertia = [ ] ai = [ - "anthropic>=0.49.0", - "openai>=1.0.0", - "google-generativeai>=0.8.0", "langchain>=1.0.0", "langchain-core>=1.0.0", - "langgraph>=1.0.0", ] [dependency-groups] @@ -73,7 +69,6 @@ dev = [ "faker>=40.13.0", "langchain>=1.0.0", "langchain-core>=1.0.0", - "langgraph>=1.0.0", ] diff --git a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py index 15771070..1ba3486d 100644 --- a/fastapi_startkit/src/fastapi_startkit/ai/fakes.py +++ b/fastapi_startkit/src/fastapi_startkit/ai/fakes.py @@ -2,10 +2,10 @@ :func:`fake_chat_model` returns a chat model that replays a scripted sequence of assistant turns. Inject it into an :class:`~fastapi_startkit.ai.Agent` by patching -``_build_model`` so :meth:`Agent.prompt` runs the genuine ``create_agent`` loop — -tool calls included — with no network. Requires the ``langgraph`` extra:: +``_build_model`` so :meth:`Agent.prompt` runs the genuine ``Runner`` loop — +tool calls included — with no network. Requires the ``ai`` extra:: - pip install "fastapi-startkit[langgraph]" + pip install "fastapi-startkit[ai]" Example — exercise a tool-calling agent end to end:: @@ -35,8 +35,8 @@ def _require_langchain(): from langchain_core.messages import AIMessage except ImportError as exc: # pragma: no cover - exercised only without the extra raise ImportError( - "The agent test harness requires the 'langgraph' extra. " - 'Install it with: pip install "fastapi-startkit[langgraph]"' + "The agent test harness requires the 'ai' extra. " + 'Install it with: pip install "fastapi-startkit[ai]"' ) from exc return GenericFakeChatModel, AIMessage From 221d930f722d09e7e5b21fb535031183262bc664 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 25 Jun 2026 13:51:12 -0700 Subject: [PATCH 14/31] fix(ai): stream through middleware, agent-driven Runner, bind tools in ModelBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming previously buffered the entire response when an agent had middleware: `final = await handler(model)` drained the Response stream before returning, so the first token only appeared after the full generation and after-hooks fired late. build_pipeline now hands each middleware a Response-returning handler so layers can attach `.then(callback)` and return without awaiting — streaming-safe, and the after-hook fires once on completion (buffered for prompt, post-stream for stream). Middleware may be sync or async; the example AgentLogger is now sync + `.then()`. Other AI changes: - ModelBuilder binds tools (agent.tools()) onto the chat model; Runner no longer binds, only keeps the tool map for execution. - Runner takes the agent (Runner(agent, model)) instead of threading tools/max_steps separately. - Agent.tools() typed as list[BaseTool]; @model decorator sets `model` (was the unread `_model`). - Tests updated to the current API (instructions() method, provider attr, ModelBuilder._resolve_model) and drop the removed `memory` decorator; add regression tests for streaming-through-middleware and the prompt after-hook. Co-Authored-By: Claude Opus 4.8 --- .../.ai/fastapi-startkit/fastapi/SKILL.md | 109 + example/agents/.env.example | 5 + example/agents/.github/workflows/lint.yml | 31 + example/agents/.github/workflows/test.yml | 37 + example/agents/.gitignore | 6 + example/agents/app/agents/chat.py | 17 + example/agents/app/middleware/agent_logger.py | 25 + .../agents/app/providers/fastapi_provider.py | 13 + example/agents/app/requests/chat.py | 5 + example/agents/app/schema/route.py | 8 + example/agents/app/tools/job_search_tool.py | 25 + example/agents/artisan | 9 + example/agents/bootstrap/application.py | 25 + example/agents/config/fastapi.py | 18 + example/agents/config/logging.py | 22 + example/agents/config/vite.py | 12 + example/agents/package-lock.json | 1454 +++++++++++ example/agents/package.json | 24 + .../public/build/assets/app-BOz0QTn4.js | 97 + .../public/build/assets/app-Bn2F_iEe.css | 2 + example/agents/public/build/manifest.json | 11 + example/agents/public/hot | 1 + example/agents/pyproject.toml | 44 + example/agents/readme.md | 40 + example/agents/resources/css/app.css | 1 + example/agents/resources/js/app.tsx | 21 + .../agents/resources/js/pages/chat/Index.tsx | 98 + .../resources/js/pages/dashboard/Index.tsx | 3 + example/agents/resources/templates/index.html | 12 + example/agents/routes/api.py | 23 + .../tests/features/other_greetings.json | 3 + .../tests/features/test_chat_controller.py | 6 +- example/agents/tests/test_case.py | 8 + example/agents/tinker.py | 63 + example/agents/tsconfig.json | 115 + example/agents/uv.lock | 2213 +++++++++++++++++ example/agents/vite.config.ts | 15 + .../src/fastapi_startkit/ai/__init__.py | 5 +- .../src/fastapi_startkit/ai/agent.py | 125 +- .../src/fastapi_startkit/ai/decorators.py | 20 +- .../src/fastapi_startkit/ai/fakes.py | 41 +- .../src/fastapi_startkit/ai/model_builder.py | 49 + .../src/fastapi_startkit/ai/pipeline.py | 90 + .../src/fastapi_startkit/ai/runner.py | 51 +- fastapi_startkit/tests/ai/test_agent.py | 69 +- .../tests/ai/test_agent_decorators.py | 67 +- fastapi_startkit/uv.lock | 429 ---- 47 files changed, 4980 insertions(+), 587 deletions(-) create mode 100644 example/agents/.ai/fastapi-startkit/fastapi/SKILL.md create mode 100644 example/agents/.env.example create mode 100644 example/agents/.github/workflows/lint.yml create mode 100644 example/agents/.github/workflows/test.yml create mode 100644 example/agents/.gitignore create mode 100644 example/agents/app/agents/chat.py create mode 100644 example/agents/app/middleware/agent_logger.py create mode 100644 example/agents/app/providers/fastapi_provider.py create mode 100644 example/agents/app/requests/chat.py create mode 100644 example/agents/app/schema/route.py create mode 100644 example/agents/app/tools/job_search_tool.py create mode 100644 example/agents/artisan create mode 100644 example/agents/bootstrap/application.py create mode 100644 example/agents/config/fastapi.py create mode 100644 example/agents/config/logging.py create mode 100644 example/agents/config/vite.py create mode 100644 example/agents/package-lock.json create mode 100644 example/agents/package.json create mode 100644 example/agents/public/build/assets/app-BOz0QTn4.js create mode 100644 example/agents/public/build/assets/app-Bn2F_iEe.css create mode 100644 example/agents/public/build/manifest.json create mode 100644 example/agents/public/hot create mode 100644 example/agents/pyproject.toml create mode 100644 example/agents/readme.md create mode 100644 example/agents/resources/css/app.css create mode 100644 example/agents/resources/js/app.tsx create mode 100644 example/agents/resources/js/pages/chat/Index.tsx create mode 100644 example/agents/resources/js/pages/dashboard/Index.tsx create mode 100644 example/agents/resources/templates/index.html create mode 100644 example/agents/routes/api.py create mode 100644 example/agents/tests/features/other_greetings.json create mode 100644 example/agents/tests/test_case.py create mode 100644 example/agents/tinker.py create mode 100644 example/agents/tsconfig.json create mode 100644 example/agents/uv.lock create mode 100644 example/agents/vite.config.ts create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/model_builder.py create mode 100644 fastapi_startkit/src/fastapi_startkit/ai/pipeline.py diff --git a/example/agents/.ai/fastapi-startkit/fastapi/SKILL.md b/example/agents/.ai/fastapi-startkit/fastapi/SKILL.md new file mode 100644 index 00000000..ace0a63b --- /dev/null +++ b/example/agents/.ai/fastapi-startkit/fastapi/SKILL.md @@ -0,0 +1,109 @@ +--- +name: fastapi-startkit +description: Routing, controllers, ORM, requests, resources, and action pattern for fastapi-startkit applications. +--- + +# Fastapi's Routing + +### Fastapi Startkit's Router +```python +# routes/web.py +from fastapi_startkit.fastapi import Router + +router = Router() +``` + +and use the crud resources, for example +```python +router.post("/users", users_controller.store) +router.put("/users/{user_id}", users_controller.update) +router.patch("/users/{user_id}", users_controller.patch) +router.delete("/users", users_controller.destroy) +``` + +the controller will look like +```python +# app/http/controllers/users_controller.py +async def index(request: Request): + pass + +async def show(user_id: int): + pass + +async def store(data: UserSchema): + pass + +async def update(user_id: int, data: UserSchema): + pass + +async def destroy(user_id: int): + pass +``` + +or use the resource function as: +```python +router.resource("users", users_controller, excepts=['create', 'edit']) +``` + +## ORM +```python +# app/models/user.py +from fastapi_startkit.masoniteorm import Model + +class User(Model): + id: int + name: str + email: str + metadata: dict +``` + +and use the orm as: +```python +# app/http/controllers/users_controller.py +from app.models import User + +async def store(request: UserStoreRequest): + user = User.create(request.model_dump()) + ... +``` + +the `UserStoreRequest` will look like: +```python +# app/http/requests/user_store_request.py +from pydantic import BaseModel + +class UserStoreRequest(BaseModel): + name: str +``` + +and use JsonApiResource to return JSON response from the controller: +```python +from fastapi_startkit.jsonapi import JsonResource + +# app/http/controllers/users_controller.py +from app.models import User + +async def store(request: UserStoreRequest): + user = User.create(request.model_dump()) + return JsonResource(user) +``` + +## Architecture + +use the action pattern to write complex logic. +```python +# app/actions/user_actions.py +from app.models import User + +class UserStoreAction: + def __init__(self, request: UserStoreRequest): + self.request = request + + @staticmethod + def prepare(request: UserStoreRequest) -> 'UserStoreAction': + return UserStoreAction(request) + + def handle(self) -> JsonResource[User]: + user = User.create(self.request.model_dump()) + return JsonResource(user) +``` diff --git a/example/agents/.env.example b/example/agents/.env.example new file mode 100644 index 00000000..10581e95 --- /dev/null +++ b/example/agents/.env.example @@ -0,0 +1,5 @@ +APP_ENV=local +APP_URL=http://127.0.0.1:7654 + +AI_PROVIDER=google +GEMINI_API_KEY= diff --git a/example/agents/.github/workflows/lint.yml b/example/agents/.github/workflows/lint.yml new file mode 100644 index 00000000..4e0c28d5 --- /dev/null +++ b/example/agents/.github/workflows/lint.yml @@ -0,0 +1,31 @@ +name: Lint + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + ruff: + name: Ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --group dev + + - name: Run ruff check + run: uv run ruff check . + + - name: Run ruff format check + run: uv run ruff format --check . diff --git a/example/agents/.github/workflows/test.yml b/example/agents/.github/workflows/test.yml new file mode 100644 index 00000000..7d587077 --- /dev/null +++ b/example/agents/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test + +on: + pull_request: + branches: ["**"] + +jobs: + tests: + name: Pytest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: uv sync --group dev + + - name: Run tests + shell: bash + run: | + set +e + uv run pytest tests/ -v + code=$? + # Exit code 5 means "no tests collected" — treat as success so the + # starter template's CI stays green until real tests are added. + if [ "$code" -eq 5 ]; then + echo "No tests collected — treating as success." + exit 0 + fi + exit $code diff --git a/example/agents/.gitignore b/example/agents/.gitignore new file mode 100644 index 00000000..3682dd97 --- /dev/null +++ b/example/agents/.gitignore @@ -0,0 +1,6 @@ +.idea +.DS_Store +.venv +**__pycache__ +storage +.env diff --git a/example/agents/app/agents/chat.py b/example/agents/app/agents/chat.py new file mode 100644 index 00000000..6d134452 --- /dev/null +++ b/example/agents/app/agents/chat.py @@ -0,0 +1,17 @@ +from typing import Callable + +from fastapi_startkit.ai import Agent, Middleware + +from app.middleware.agent_logger import AgentLogger +from app.tools.job_search_tool import job_search_tool + + +class RouterAgent(Agent): + def middleware(self) -> list[Middleware]: + return [AgentLogger()] + + def tools(self) -> list[Callable]: + return [job_search_tool] + + def instructions(self) -> str: + return "You are a friendly customer support assistant." diff --git a/example/agents/app/middleware/agent_logger.py b/example/agents/app/middleware/agent_logger.py new file mode 100644 index 00000000..8fd8d7b9 --- /dev/null +++ b/example/agents/app/middleware/agent_logger.py @@ -0,0 +1,25 @@ +import time +from collections.abc import Callable +from typing import Any + +from langchain_core.language_models.chat_models import BaseChatModel + +from fastapi_startkit.logging import Logger + + +def _model_name(model: BaseChatModel) -> str: + return getattr(model, "model", None) or getattr(model, "model_name", None) or type(model).__name__ + + +class AgentLogger: + def handle(self, model: BaseChatModel, handler: Callable) -> Any: + Logger.info(f"request | model={_model_name(model)}") + started_at = time.monotonic() + + def log_response(final: Any) -> None: + elapsed = time.monotonic() - started_at + meta = getattr(final, "usage_metadata", None) or {} + preview = str(getattr(final, "content", final) or "")[:200].replace("\n", " ") + Logger.info(f"response | {elapsed:.2f}s | in={meta.get('input_tokens', '?')} out={meta.get('output_tokens', '?')} tokens | {preview}") + + return handler(model).then(log_response) diff --git a/example/agents/app/providers/fastapi_provider.py b/example/agents/app/providers/fastapi_provider.py new file mode 100644 index 00000000..f015a34c --- /dev/null +++ b/example/agents/app/providers/fastapi_provider.py @@ -0,0 +1,13 @@ +from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider +from fastapi.templating import Jinja2Templates + + +class FastapiProvider(BaseFastAPIProvider): + def boot(self): + templates_dir = self.app.use_base_path("resources/templates") + self.app.bind("templates", Jinja2Templates(directory=str(templates_dir))) + + super().boot() + from routes.api import api + + self.app.fastapi.include_router(api) diff --git a/example/agents/app/requests/chat.py b/example/agents/app/requests/chat.py new file mode 100644 index 00000000..163422ac --- /dev/null +++ b/example/agents/app/requests/chat.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel, Field + + +class ChatRequest(BaseModel): + message: str = Field(...) diff --git a/example/agents/app/schema/route.py b/example/agents/app/schema/route.py new file mode 100644 index 00000000..00aa53f1 --- /dev/null +++ b/example/agents/app/schema/route.py @@ -0,0 +1,8 @@ +from enum import StrEnum + +from pydantic import BaseModel + + +class Route(StrEnum): + JOB_SEARCH: str + CHAT: str diff --git a/example/agents/app/tools/job_search_tool.py b/example/agents/app/tools/job_search_tool.py new file mode 100644 index 00000000..4d76fd83 --- /dev/null +++ b/example/agents/app/tools/job_search_tool.py @@ -0,0 +1,25 @@ +from langchain_core.tools import tool + +jobs = [ + {"id": 1, "title": "Software Engineer", "location": "San Francisco", "company": "Acme Corp", "type": "Full-time"}, + {"id": 2, "title": "Frontend Developer", "location": "Remote", "company": "Startup Inc", "type": "Full-time"}, + {"id": 3, "title": "Data Scientist", "location": "New York", "company": "DataCo", "type": "Full-time"}, + {"id": 4, "title": "DevOps Engineer", "location": "Austin", "company": "CloudBase", "type": "Contract"}, + {"id": 5, "title": "Product Manager", "location": "Remote", "company": "ProductHQ", "type": "Full-time"}, +] + + +@tool +def job_search_tool(query: str) -> list: + """Searches for jobs based on the given query. Supports wildcards (* and ?) in each term.""" + import fnmatch + + patterns = [f"*{term}*" for term in query.lower().split()] + + return [ + job for job in jobs + if any( + fnmatch.fnmatch(" ".join(str(v) for v in job.values()).lower(), pattern) + for pattern in patterns + ) + ] diff --git a/example/agents/artisan b/example/agents/artisan new file mode 100644 index 00000000..2a546fc4 --- /dev/null +++ b/example/agents/artisan @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import sys + +from bootstrap.application import app + +if __name__ == "__main__": + status = app.handle_command() + sys.exit(status if isinstance(status, int) else 0) diff --git a/example/agents/bootstrap/application.py b/example/agents/bootstrap/application.py new file mode 100644 index 00000000..f198c0a0 --- /dev/null +++ b/example/agents/bootstrap/application.py @@ -0,0 +1,25 @@ +from pathlib import Path + +from fastapi_startkit import Application +from fastapi_startkit.inertia import InertiaProvider +from fastapi_startkit.logging import LogProvider +from fastapi_startkit.skills import AISkillProvider +from fastapi_startkit.vite import ViteProvider +from fastapi_startkit.ai import AIProvider + +from config.fastapi import FastAPIConfig +from config.logging import LoggingConfig +from config.vite import ViteConfig +from app.providers.fastapi_provider import FastapiProvider + +app: Application = Application( + base_path=Path(__file__).resolve().parent.parent, + providers=[ + AISkillProvider, + (LogProvider,LoggingConfig), + (FastapiProvider, FastAPIConfig), + AIProvider, + (ViteProvider, ViteConfig), + InertiaProvider, + ], +) diff --git a/example/agents/config/fastapi.py b/example/agents/config/fastapi.py new file mode 100644 index 00000000..e6cd55ab --- /dev/null +++ b/example/agents/config/fastapi.py @@ -0,0 +1,18 @@ +import dataclasses + +from fastapi_startkit.environment import env + + +@dataclasses.dataclass +class FastAPIConfig: + app_url: str = dataclasses.field(default_factory=lambda: env("APP_URL", "http://127.0.0.1:8000")) + reload: bool = dataclasses.field(default_factory=lambda: env("APP_RELOAD", True)) + reload_dirs: list | None = None + reload_excludes: list = dataclasses.field( + default_factory=lambda: [ + "*.log", + "tests/*", + "node_modules/*", + "storage/*", + ] + ) diff --git a/example/agents/config/logging.py b/example/agents/config/logging.py new file mode 100644 index 00000000..553a7426 --- /dev/null +++ b/example/agents/config/logging.py @@ -0,0 +1,22 @@ +import dataclasses + +from fastapi_startkit.environment import env +from fastapi_startkit.logging.config import StackChannel, DailyChannel, TerminalChannel + + +@dataclasses.dataclass +class LoggingConfig: + default: str = dataclasses.field(default_factory=lambda: env("LOG_CHANNEL", "stack")) + + channels: dict = dataclasses.field( + default_factory=lambda: { + "stack": StackChannel(driver="stack", channels=["daily", "terminal"]), + "daily": DailyChannel( + level=env("LOG_DAILY_LEVEL", "debug"), + path=env("LOG_DAILY_PATH", "storage/logs"), + ), + "terminal": TerminalChannel( + level=env("LOG_TERMINAL_LEVEL", "info"), + ), + } + ) diff --git a/example/agents/config/vite.py b/example/agents/config/vite.py new file mode 100644 index 00000000..71431700 --- /dev/null +++ b/example/agents/config/vite.py @@ -0,0 +1,12 @@ +from pydantic.dataclasses import dataclass + + +@dataclass +class ViteConfig: + public_path: str = "public" + build_directory: str = "build" + hot_file: str = "hot" + manifest_filename: str = "manifest.json" + asset_url: str = "" + static_url: str = "/build" + mount_static: bool = True diff --git a/example/agents/package-lock.json b/example/agents/package-lock.json new file mode 100644 index 00000000..64a422b0 --- /dev/null +++ b/example/agents/package-lock.json @@ -0,0 +1,1454 @@ +{ + "name": "agents", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@inertiajs/react": "^3.4.0", + "@vitejs/plugin-react": "^6.0.2", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "fastapi-vite-plugin": "^0.0.3", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^8.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inertiajs/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.4.0.tgz", + "integrity": "sha512-3O+My9yhY1NliwyaEFS2OhLpnNb73mWIgAJEeh6KnBL5lJKy5P5TlvmjR4ROWJhMjVmvtbkiJ/sQ2n4qazsH8g==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "axios": "^1.15.2" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/@inertiajs/react": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@inertiajs/react/-/react-3.4.0.tgz", + "integrity": "sha512-Syp4YsFbtEnPwUUrbiDX5gZjQwYiluDkYN9GFqHpY1XjKebmABrS3Zbq5Ydu12QoC+xu8Eec9VVjnQFaBa1boA==", + "license": "MIT", + "dependencies": { + "@inertiajs/core": "3.4.0", + "es-toolkit": "^1.33.0", + "laravel-precognition": "^2.0.0" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-toolkit": { + "version": "1.47.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", + "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/fastapi-vite-plugin": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/fastapi-vite-plugin/-/fastapi-vite-plugin-0.0.3.tgz", + "integrity": "sha512-BzUPUquR5/pHZ36Id7jtudQHyn09r2SuP2EnmjUHXLJWpcZyh0i2w7bWtriwA9wLjgw+ZLmlWgI/Rf8EyYtdOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^8.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-precognition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz", + "integrity": "sha512-dmA4HGc9m+TsVNsJs9/XQBI8u6j7coilN+qKkBuhuXQzH3HypwS/c5dFQ4UqUGjBbcxIM7zdk91kM/SRZwIvWQ==", + "license": "MIT", + "dependencies": { + "es-toolkit": "^1.32.0" + }, + "peerDependencies": { + "axios": "^1.4.0" + }, + "peerDependenciesMeta": { + "axios": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + } + } +} diff --git a/example/agents/package.json b/example/agents/package.json new file mode 100644 index 00000000..4296bade --- /dev/null +++ b/example/agents/package.json @@ -0,0 +1,24 @@ +{ + "private": true, + "type": "module", + "scripts": { + "dev": "concurrently -c \"#fb7185,#fdba74\" \"uv run python artisan serve\" \"vite\"", + "build": "vite build", + "types:check": "tsc --noEmit" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "fastapi-vite-plugin": "^0.0.3", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^8.0.0" + }, + "dependencies": { + "@inertiajs/react": "^3.4.0", + "@vitejs/plugin-react": "^6.0.2", + "react": "^19.2.7", + "react-dom": "^19.2.7" + } +} diff --git a/example/agents/public/build/assets/app-BOz0QTn4.js b/example/agents/public/build/assets/app-BOz0QTn4.js new file mode 100644 index 00000000..78eb0709 --- /dev/null +++ b/example/agents/public/build/assets/app-BOz0QTn4.js @@ -0,0 +1,97 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},w=Object.prototype.hasOwnProperty;function T(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function E(e,t){return T(e.type,t,e.props)}function te(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function ne(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var re=/\/+/g;function ie(e,t){return typeof e==`object`&&e&&e.key!=null?ne(``+e.key):t.toString(36)}function ae(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function D(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,D(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+ie(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(re,`$&/`)+`/`),D(o,r,i,``,function(e){return e})):o!=null&&(te(o)&&(o=E(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(re,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=u()})),f=o((e=>{var t=Symbol.for(`react.transitional.element`);function n(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.jsx=n,e.jsxs=n})),p=o(((e,t)=>{t.exports=f()})),m=s({default:()=>_}),h=l(d(),1),g=p();function _(){let[e,t]=(0,h.useState)([]),[n,r]=(0,h.useState)(``),[i,a]=(0,h.useState)(!1),o=(0,h.useRef)(null);return(0,h.useEffect)(()=>{o.current?.scrollIntoView({behavior:`smooth`})},[e]),(0,g.jsxs)(`div`,{className:`flex flex-col h-screen max-w-2xl mx-auto p-4`,children:[(0,g.jsx)(`h1`,{className:`text-xl font-bold mb-4`,children:`Chat`}),(0,g.jsxs)(`div`,{className:`flex-1 overflow-y-auto space-y-3 mb-4`,children:[e.length===0&&(0,g.jsx)(`p`,{className:`text-center text-gray-400 mt-8`,children:`Send a message to start chatting.`}),e.map((t,n)=>(0,g.jsx)(`div`,{className:`flex ${t.role===`user`?`justify-end`:`justify-start`}`,children:(0,g.jsx)(`div`,{className:`max-w-sm px-4 py-2 rounded-2xl whitespace-pre-wrap ${t.role===`user`?`bg-blue-500 text-white`:`bg-gray-100 text-gray-800`}`,children:t.content||(i&&n===e.length-1?`▋`:``)})},n)),(0,g.jsx)(`div`,{ref:o})]}),(0,g.jsxs)(`form`,{onSubmit:async e=>{if(e.preventDefault(),!n.trim()||i)return;let o=n.trim();r(``),t(e=>[...e,{role:`user`,content:o}]),a(!0),t(e=>[...e,{role:`assistant`,content:``}]);try{let e=(await fetch(`/chat`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({message:o})})).body?.getReader(),n=new TextDecoder;if(!e)return;for(;;){let{done:r,value:i}=await e.read();if(r)break;let a=n.decode(i,{stream:!0});t(e=>{let t=[...e];return t[t.length-1]={role:`assistant`,content:t[t.length-1].content+a},t})}}finally{a(!1)}},className:`flex gap-2`,children:[(0,g.jsx)(`input`,{className:`flex-1 border rounded-xl px-4 py-2 outline-none focus:ring-2 focus:ring-blue-400`,type:`text`,value:n,onChange:e=>r(e.target.value),placeholder:`Type a message...`,disabled:i}),(0,g.jsx)(`button`,{type:`submit`,disabled:i||!n.trim(),className:`bg-blue-500 text-white px-5 py-2 rounded-xl disabled:opacity-50 hover:bg-blue-600 transition-colors`,children:`Send`})]})]})}var v=s({default:()=>y});function y({user:e}){return(0,g.jsx)(`h1`,{children:`Welcome`})}function b(e){return typeof e==`symbol`||e instanceof Symbol}var x=typeof globalThis==`object`&&globalThis||typeof window==`object`&&window||typeof self==`object`&&self||typeof global==`object`&&global||(function(){return this})();function ee(e,t,{signal:n,edges:r}={}){let i,a=null,o=r!=null&&r.includes(`leading`),s=r==null||r.includes(`trailing`),c=()=>{a!==null&&(e.apply(i,a),i=void 0,a=null)},l=()=>{s&&c(),p()},u=null,d=()=>{u!=null&&clearTimeout(u),u=setTimeout(()=>{u=null,l()},t)},f=()=>{u!==null&&(clearTimeout(u),u=null)},p=()=>{f(),i=void 0,a=null},m=()=>{c()},h=function(...e){if(n?.aborted)return;i=this,a=e;let t=u==null;d(),o&&t&&c()};return h.schedule=d,h.cancel=p,h.flush=m,n?.addEventListener(`abort`,p,{once:!0}),h}function S(){}function C(e){return e==null||typeof e!=`object`&&typeof e!=`function`}function w(e){return ArrayBuffer.isView(e)&&!(e instanceof DataView)}function T(e){if(C(e))return e;if(Array.isArray(e)||w(e)||e instanceof ArrayBuffer||typeof SharedArrayBuffer<`u`&&e instanceof SharedArrayBuffer)return e.slice(0);let t=Object.getPrototypeOf(e);if(t==null)return Object.assign(Object.create(t),e);let n=t.constructor;if(e instanceof Date||e instanceof Map||e instanceof Set)return new n(e);if(e instanceof RegExp){let t=new n(e);return t.lastIndex=e.lastIndex,t}if(e instanceof DataView)return new n(e.buffer.slice(0));if(e instanceof Error){let t;return t=e instanceof AggregateError?new n(e.errors,e.message,{cause:e.cause}):new n(e.message,{cause:e.cause}),t.stack=e.stack,Object.assign(t,e),t}return typeof File<`u`&&e instanceof File?new n([e],e.name,{type:e.type,lastModified:e.lastModified}):typeof e==`object`?Object.assign(Object.create(t),e):e}function E(e){return x.Buffer!==void 0&&x.Buffer.isBuffer(e)}function te(e){return Object.getOwnPropertySymbols(e).filter(t=>Object.prototype.propertyIsEnumerable.call(e,t))}function ne(e){return e==null?e===void 0?`[object Undefined]`:`[object Null]`:Object.prototype.toString.call(e)}var re=`[object RegExp]`,ie=`[object String]`,ae=`[object Number]`,D=`[object Boolean]`,O=`[object Arguments]`,k=`[object Symbol]`,A=`[object Date]`,oe=`[object Map]`,se=`[object Set]`,ce=`[object Array]`,j=`[object Function]`,M=`[object ArrayBuffer]`,N=`[object Object]`,le=`[object Error]`,ue=`[object DataView]`,de=`[object Uint8Array]`,fe=`[object Uint8ClampedArray]`,pe=`[object Uint16Array]`,P=`[object Uint32Array]`,me=`[object BigUint64Array]`,he=`[object Int8Array]`,ge=`[object Int16Array]`,_e=`[object Int32Array]`,ve=`[object BigInt64Array]`,ye=`[object Float32Array]`,be=`[object Float64Array]`;function xe(e,t){return Se(e,void 0,e,new Map,t)}function Se(e,t,n,r=new Map,i=void 0){let a=i?.(e,t,n,r);if(a!==void 0)return a;if(C(e))return e;if(r.has(e))return r.get(e);if(Array.isArray(e)){let t=Array(e.length);r.set(e,t);for(let a=0;aAe(s,i,void 0,e,t,n,r));if(c===-1)return!1;a.splice(c,1)}return!0}case ce:case de:case fe:case pe:case P:case me:case he:case ge:case _e:case ve:case ye:case be:if(E(e)!==E(t)||e.length!==t.length)return!1;for(let i=0;i=0}function Pe(e){return e!=null&&typeof e!=`function`&&Ne(e.length)}function Fe(e){if(e==null)return``;if(typeof e==`string`)return e;if(Array.isArray(e))return e.map(Fe).join(`,`);let t=String(e);return t===`0`&&Object.is(Number(e),-0)?`-0`:t}function Ie(e){return typeof e==`string`||typeof e==`symbol`?e:Object.is(e?.valueOf?.(),-0)?`-0`:String(e)}function Le(e){if(Array.isArray(e))return e.map(Ie);if(typeof e==`symbol`)return[e];e=Fe(e);let t=[],n=e.length;if(n===0)return t;let r=0,i=``,a=``,o=!1;for(e.charCodeAt(0)===46&&(t.push(``),r++);r{let o=t?.(n,r,i,a);if(o!==void 0)return o;if(typeof e==`object`){if(ne(e)===`[object Object]`&&typeof e.constructor!=`function`){let t={};return a.set(e,t),Ce(t,e,i,a),t}switch(Object.prototype.toString.call(e)){case ae:case ie:case D:{let t=new e.constructor(e?.valueOf());return Ce(t,e),t}case O:{let t={};return Ce(t,e),t.length=e.length,t[Symbol.iterator]=e[Symbol.iterator],t}default:return}}})}function He(e){return Ve(e)}function Ue(e){return typeof e==`object`&&!!e&&ne(e)===`[object Arguments]`}var We=/^(?:0|[1-9]\d*)$/;function Ge(e,t=2**53-1){switch(typeof e){case`number`:return Number.isInteger(e)&&e>=0&&e{let r=e[t];(!(Object.hasOwn(e,t)&&Oe(r,n))||n===void 0&&!(t in e))&&(e[t]=n)};function $e(e,t,n,r){if(e==null&&!Be(e))return e;let i;i=Ze(t,e)?[t]:Array.isArray(t)?t:Le(t);let a=n(I(e,i)),o=e;for(let t=0;tn,()=>void 0)}function tt(e,t=0,n={}){typeof n!=`object`&&(n={});let{leading:r=!1,trailing:i=!0,maxWait:a}=n,o=[,,];r&&(o[0]=`leading`),i&&(o[1]=`trailing`);let s,c=null,l=ee(function(...t){s=e.apply(this,t),c=null},t,{edges:o}),u=function(...t){return a!=null&&(c===null&&(c=Date.now()),Date.now()-c>=a)?(s=e.apply(this,t),c=Date.now(),l.cancel(),l.schedule(),s):(l.apply(this,t),s)};return u.cancel=l.cancel,u.flush=()=>(l.flush(),s),u}function nt(e){return w(e)}function rt(e,...t){let n=t.slice(0,-1),r=t[t.length-1],i=e;for(let e=0;etypeof File<`u`&&e instanceof File||e instanceof Blob||typeof FileList<`u`&&e instanceof FileList&&e.length>0,st=e=>e instanceof FormData?!0:ot(e)||typeof e==`object`&&!!e&&Object.values(e).some(e=>st(e)),ct=class extends Error{response;constructor(e){super(`HTTP error ${e.status}`),this.name=`HttpResponseError`,this.response=e}},lt=class extends Error{constructor(e=`Request was cancelled`){super(e),this.name=`HttpCancelledError`}},ut=class extends Error{constructor(e=`Network error`){super(e),this.name=`HttpNetworkError`}};function dt(e){let t=new URLSearchParams;return Object.entries(e).forEach(([e,n])=>{n!=null&&(Array.isArray(n)?n.forEach(n=>t.append(`${e}[]`,String(n))):typeof n==`object`?t.append(e,JSON.stringify(n)):t.append(e,String(n)))}),t.toString()}function ft(e,t,n){if(t&&!e.startsWith(`http://`)&&!e.startsWith(`https://`)&&(e=t.replace(/\/$/,``)+`/`+e.replace(/^\//,``)),n&&Object.keys(n).length>0){let t=dt(n);t&&(e+=(e.includes(`?`)?`&`:`?`)+t)}return e}function pt(){return typeof window>`u`?null:window.axios?.defaults?.headers?.common?.[`X-Requested-With`]??null}function mt(e,t=new FormData,n=null){for(let r in e)Object.prototype.hasOwnProperty.call(e,r)&&ht(t,n?`${n}[${r}]`:r,e[r]);return t}function ht(e,t,n){if(Array.isArray(n))return n.forEach((n,r)=>ht(e,`${t}[${r}]`,n));if(n instanceof Date)return e.append(t,n.toISOString());if(typeof File<`u`&&n instanceof File)return e.append(t,n,n.name);if(n instanceof Blob)return e.append(t,n);if(typeof n==`boolean`)return e.append(t,n?`1`:`0`);if(typeof n==`string`)return e.append(t,n);if(typeof n==`number`)return e.append(t,`${n}`);if(n==null)return e.append(t,``);mt(n,e,t)}function gt(e,t){if(e!=null)return e instanceof FormData?e:typeof e==`object`&&st(e)?mt(e):typeof e==`object`||t[`Content-Type`]?.includes(`application/json`)?JSON.stringify(e):String(e)}function _t(e){let t={};return e.forEach((e,n)=>{t[n.toLowerCase()]=e}),t}function vt(e={}){let t=e.xsrfCookieName??`XSRF-TOKEN`,n=e.xsrfHeaderName??`X-XSRF-TOKEN`;function r(){if(typeof document>`u`)return null;let e=document.cookie.match(RegExp(`(^|;\\s*)`+t+`=([^;]*)`));return e?decodeURIComponent(e[2]):null}return{setXsrfCookieName(e){t=e},setXsrfHeaderName(e){n=e},async request(e){let t=ft(e.url,e.baseURL,e.params),i=e.method.toUpperCase(),a={},o=pt();o&&(a[`X-Requested-With`]=o),e.data!==void 0&&![`GET`,`DELETE`].includes(i)&&!(e.data instanceof FormData)&&!st(e.data)&&(a[`Content-Type`]=`application/json`),e.headers&&Object.entries(e.headers).forEach(([e,t])=>{t!==void 0&&(a[e]=String(t))});let s=r();s&&![`GET`,`HEAD`,`OPTIONS`].includes(i)&&(a[n]=s);let c=e.signal,l,u=e.timeout??3e4;if(u>0&&!c){let e=new AbortController;c=e.signal,l=setTimeout(()=>e.abort(),u)}let d=[`GET`,`DELETE`].includes(i)?void 0:gt(e.data,a);d instanceof FormData&&delete a[`Content-Type`];try{let n=await fetch(t,{method:i,headers:a,body:d,signal:c,credentials:e.credentials??`same-origin`});l&&clearTimeout(l);let r;r=n.headers.get(`content-type`)?.includes(`application/json`)?await n.json():await n.text();let o={status:n.status,data:r,headers:_t(n.headers)};if(!n.ok)throw new ct(o);return o}catch(e){throw l&&clearTimeout(l),e instanceof ct?e:e instanceof DOMException&&e.name===`AbortError`?new lt:e instanceof TypeError?new ut(e.message):e}}}}var yt=vt(),bt=yt,xt=void 0,St=void 0,Ct=`same-origin`,wt=e=>`${e.method}:${e.baseURL??xt??``}${e.url}`,Tt=e=>e.status===204&&e.headers[`precognition-success`]===`true`,Et={},Dt={get:(e,t={},n={})=>kt(Ot(`get`,e,t,n)),post:(e,t={},n={})=>kt(Ot(`post`,e,t,n)),patch:(e,t={},n={})=>kt(Ot(`patch`,e,t,n)),put:(e,t={},n={})=>kt(Ot(`put`,e,t,n)),delete:(e,t={},n={})=>kt(Ot(`delete`,e,t,n)),useHttpClient(e){return bt=e,Dt},withBaseURL(e){return xt=e,Dt},withTimeout(e){return St=e,Dt},withCredentials(e){return Ct=typeof e==`string`?e:e?`include`:`omit`,Dt},fingerprintRequestsUsing(e){return wt=e===null?()=>null:e,Dt},determineSuccessUsing(e){return Tt=e,Dt},withXsrfCookieName(e){return yt.setXsrfCookieName(e),Dt},withXsrfHeaderName(e){return yt.setXsrfHeaderName(e),Dt}},Ot=(e,t,n,r)=>({url:t,method:e,...r,...[`get`,`delete`].includes(e)?{params:at({},n,r?.params)}:{data:at({},n,r?.data)}}),kt=(e={})=>{let t=[At,Mt,Nt].reduce((e,t)=>t(e),e);return(t.onBefore??(()=>!0))()===!1?Promise.resolve(null):((t.onStart??(()=>null))(),bt.request({method:t.method,url:t.url,baseURL:t.baseURL??xt,data:t.data,params:t.params,headers:t.headers,signal:t.signal,timeout:t.timeout,credentials:Ct}).then(async e=>{t.precognitive&&Pt(e);let n=e.status,r=e;return t.precognitive&&t.onPrecognitionSuccess&&Tt(e)&&(r=await Promise.resolve(t.onPrecognitionSuccess(e)??r)),t.onSuccess&&jt(n)&&(r=await Promise.resolve(t.onSuccess(r)??r)),(It(t,n)??(e=>e))(r)??r},e=>{if(Ft(e))return Promise.reject(e);let n=e;return t.precognitive&&Pt(n.response),(It(t,n.response.status)??((e,t)=>Promise.reject(t)))(n.response,n)}).finally(t.onFinish??(()=>null)))},At=e=>{let t=e.only??e.validate;return{...e,timeout:e.timeout??St,precognitive:e.precognitive!==!1,fingerprint:e.fingerprint===void 0?wt(e,bt):e.fingerprint,headers:{...e.headers,Accept:`application/json`,"Content-Type":Lt(e),...e.precognitive===!1?{}:{Precognition:!0},...t?{"Precognition-Validate-Only":Array.from(t).join()}:{}}}},jt=e=>e>=200&&e<300,Mt=e=>typeof e.fingerprint==`string`?(Et[e.fingerprint]?.abort(),delete Et[e.fingerprint],e):e,Nt=e=>typeof e.fingerprint!=`string`||e.signal||!e.precognitive?e:(Et[e.fingerprint]=new AbortController,{...e,signal:Et[e.fingerprint].signal}),Pt=e=>{if(e.headers?.precognition!==`true`)throw Error(`Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.`)},Ft=e=>!(e instanceof ct)||typeof e.response?.status!=`number`,It=(e,t)=>({401:e.onUnauthorized,403:e.onForbidden,404:e.onNotFound,409:e.onConflict,422:e.onValidationError,423:e.onLocked})[t],Lt=e=>e.headers?.[`Content-Type`]??e.headers?.[`Content-type`]??e.headers?.[`content-type`]??(st(e.data)?`multipart/form-data`:`application/json`),Rt=(e,t)=>{if(!e.includes(`*`))return[e];let n=e.split(`.`),r=[``];for(let e of n)if(e===`*`){let e=[];for(let n of r){let r=n?I(t,n):t;if(Array.isArray(r))for(let t=0;tt?`${t}.${e}`:e);return r},zt=(e,t)=>t.includes(`*`)?RegExp(`^`+t.replace(/\./g,`\\.`).replace(/\*/g,`[^.]+`)+`$`).test(e):e===t,Bt=(e,t)=>Object.fromEntries(Object.entries(e).filter(([e])=>!t.some(t=>zt(e,t)))),Vt=(e,t={})=>{let n={errorsChanged:[],touchedChanged:[],validatingChanged:[],validatedChanged:[]},r=!1,i=!1,a=e=>e===i?[]:(i=e,n.validatingChanged),o=[],s=e=>{let t=[...new Set(e)];return o.length!==t.length||!t.every(e=>o.includes(e))?(o=t,n.validatedChanged):[]},c=()=>o.filter(e=>d[e]===void 0),l=[],u=e=>{let t=[...new Set(e)];return l.length!==t.length||!t.every(e=>l.includes(e))?(l=t,n.touchedChanged):[]},d={},f=e=>{let t=Ut(e);return Me(d,t)?[]:(d=t,n.errorsChanged)},p=e=>{let t={...d};return delete t[Wt(e)],f(t)},m=()=>Object.keys(d).length>0,h=1500,g=e=>{h=e,ee.cancel(),ee=x()},_=t,v=null,y=[],b=null,x=()=>tt(t=>{e({get:(e,n={},r={})=>Dt.get(e,w(n),S(r,t,n)),post:(e,n={},r={})=>Dt.post(e,w(n),S(r,t,n)),patch:(e,n={},r={})=>Dt.patch(e,w(n),S(r,t,n)),put:(e,n={},r={})=>Dt.put(e,w(n),S(r,t,n)),delete:(e,n={},r={})=>Dt.delete(e,w(n),S(r,t,n))}).catch(e=>e instanceof lt||e instanceof ct&&e.response?.status===422?null:Promise.reject(e))},h,{leading:!0,trailing:!0}),ee=x(),S=(e,t,n={})=>{let r={...e,...t},i=Array.from(r.only??r.validate??l);return{...t,...at({},e,t),only:i,timeout:r.timeout??5e3,onValidationError:(e,t)=>([...s([...o,...i]),...f(at(Bt({...d},i),e.data.errors))].forEach(e=>e()),r.onValidationError?r.onValidationError(e,t):Promise.reject(t)),onSuccess:e=>(s([...o,...i]).forEach(e=>e()),r.onSuccess?r.onSuccess(e):e),onPrecognitionSuccess:e=>([...s([...o,...i]),...f(Bt({...d},i))].forEach(e=>e()),r.onPrecognitionSuccess?r.onPrecognitionSuccess(e):e),onBefore:()=>{let e=l.some(e=>e.includes(`*`)),t=e?[...new Set(l.flatMap(e=>Rt(e,n)))]:l;return r.onBeforeValidation&&r.onBeforeValidation({data:n,touched:t},{data:_,touched:y})===!1||(r.onBefore||(()=>!0))()===!1?!1:(e&&u(t).forEach(e=>e()),b=l,v=n,!0)},onStart:()=>{a(!0).forEach(e=>e()),(r.onStart??(()=>null))()},onFinish:()=>{a(!1).forEach(e=>e()),y=b,_=v,b=v=null,(r.onFinish??(()=>null))()}}},C=(e,t,n)=>{if(e===void 0){let e=Array.from(n?.only??n?.validate??[]);u([...l,...e]).forEach(e=>e()),ee(n??{});return}if(ot(t)&&!r){console.warn(`Precognition file validation is not active. Call the "validateFiles" function on your form to enable it.`);return}e=Wt(e),(e.includes(`*`)||I(_,e)!==t)&&(u([e,...l]).forEach(e=>e()),ee(n??{}))},w=e=>r===!1?Gt(e):e,T={touched:()=>l,validate(e,t,n){return typeof e==`object`&&!(`target`in e)&&(n=e,e=t=void 0),C(e,t,n),T},touch(e){let t=Array.isArray(e)?e:[Wt(e)];return u([...l,...t]).forEach(e=>e()),T},validating:()=>i,valid:c,errors:()=>d,hasErrors:m,setErrors(e){return f(e).forEach(e=>e()),T},forgetError(e){return p(e).forEach(e=>e()),T},defaults(e){return t=e,_=e,T},reset(...e){if(e.length===0)u([]).forEach(e=>e());else{let n=[...l];e.forEach(e=>{n.includes(e)&&n.splice(n.indexOf(e),1),et(_,e,I(t,e))}),u(n).forEach(e=>e())}return T},setTimeout(e){return g(e),T},on(e,t){return n[e].push(t),T},validateFiles(){return r=!0,T},withoutFileValidation(){return r=!1,T}};return T},Ht=e=>Object.keys(e).reduce((t,n)=>({...t,[n]:Array.isArray(e[n])?e[n][0]:e[n]}),{}),Ut=e=>Object.keys(e).reduce((t,n)=>({...t,[n]:typeof e[n]==`string`?[e[n]]:e[n]}),{}),Wt=e=>typeof e==`string`?e:e.target.name,Gt=e=>{let t={...e};return Object.keys(t).forEach(e=>{let n=t[e];if(n!==null){if(ot(n)){delete t[e];return}if(Array.isArray(n)){t[e]=Object.values(Gt({...n}));return}if(typeof n==`object`){t[e]=Gt(t[e]);return}}}),t},Kt=new class{config={};defaults;constructor(e){this.defaults=e}extend(e){return e&&(this.defaults={...this.defaults,...e}),this}replace(e){this.config=e}get(e){return Ke(this.config,e)?I(this.config,e):I(this.defaults,e)}set(e,t){typeof e==`string`?et(this.config,e,t):Object.entries(e).forEach(([e,t])=>{et(this.config,e,t)})}}({form:{recentlySuccessfulDuration:2e3,forceIndicesArrayFormatInFormData:!0,withAllErrors:!1},prefetch:{cacheFor:3e4,hoverDelay:75}});function qt(e,t){let n;return function(...r){clearTimeout(n),n=setTimeout(()=>e.apply(this,r),t)}}function Jt(e,t){return document.dispatchEvent(new CustomEvent(`inertia:${e}`,t))}var Yt=e=>Jt(`before`,{cancelable:!0,detail:{visit:e}}),Xt=(e,{page:t,visitId:n}={})=>Jt(`error`,{detail:{errors:e,page:t,visitId:n}}),Zt=e=>Jt(`networkError`,{cancelable:!0,detail:{error:e}}),Qt=e=>Jt(`finish`,{detail:{visit:e}}),$t=e=>Jt(`httpException`,{cancelable:!0,detail:{response:e}}),en=e=>Jt(`beforeUpdate`,{detail:{page:e}}),tn=(e,{cached:t=!1,visitId:n}={})=>Jt(`navigate`,{detail:{page:e,cached:t,visitId:n}}),nn=(e,{replace:t,visitId:n})=>Jt(`clientVisit`,{detail:{page:e,replace:t,visitId:n}}),rn=e=>Jt(`progress`,{detail:{progress:e}}),an=e=>Jt(`start`,{detail:{visit:e}}),on=(e,{visitId:t}={})=>Jt(`success`,{detail:{page:e,visitId:t}}),sn=(e,t)=>Jt(`prefetched`,{detail:{fetchedAt:Date.now(),response:e,visit:t}}),cn=e=>Jt(`prefetching`,{detail:{visit:e}}),ln=e=>Jt(`flash`,{detail:{flash:e}}),un=class{static locationVisitKey=`inertiaLocationVisit`;static set(e,t){typeof window<`u`&&window.sessionStorage.setItem(e,JSON.stringify(t))}static get(e){if(typeof window<`u`)return JSON.parse(window.sessionStorage.getItem(e)||`null`)}static merge(e,t){let n=this.get(e);n===null?this.set(e,t):this.set(e,{...n,...t})}static remove(e){typeof window<`u`&&window.sessionStorage.removeItem(e)}static removeNested(e,t){let n=this.get(e);n!==null&&(delete n[t],this.set(e,n))}static exists(e){try{return this.get(e)!==null}catch{return!1}}static clear(){typeof window<`u`&&window.sessionStorage.clear()}},dn=async e=>{if(typeof window>`u`)throw Error(`Unable to encrypt history`);let t=gn(),n=await yn(await bn());if(!n)throw Error(`Unable to encrypt history`);return await mn(t,n,e)},fn={key:`historyKey`,iv:`historyIv`},pn=async e=>{let t=gn(),n=await bn();if(!n)throw Error(`Unable to decrypt history`);return await hn(t,n,e)},mn=async(e,t,n)=>{if(typeof window>`u`)throw Error(`Unable to encrypt history`);if(window.crypto.subtle===void 0)return console.warn(`Encryption is not supported in this environment. SSL is required.`),Promise.resolve(n);let r=new TextEncoder,i=JSON.stringify(n),a=new Uint8Array(i.length*3),o=r.encodeInto(i,a);return window.crypto.subtle.encrypt({name:`AES-GCM`,iv:e},t,a.subarray(0,o.written))},hn=async(e,t,n)=>{if(window.crypto.subtle===void 0)return console.warn(`Decryption is not supported in this environment. SSL is required.`),Promise.resolve(n);let r=await window.crypto.subtle.decrypt({name:`AES-GCM`,iv:e},t,n);return JSON.parse(new TextDecoder().decode(r))},gn=()=>{let e=un.get(fn.iv);if(e)return new Uint8Array(e);let t=window.crypto.getRandomValues(new Uint8Array(12));return un.set(fn.iv,Array.from(t)),t},_n=async()=>window.crypto.subtle===void 0?(console.warn(`Encryption is not supported in this environment. SSL is required.`),Promise.resolve(null)):window.crypto.subtle.generateKey({name:`AES-GCM`,length:256},!0,[`encrypt`,`decrypt`]),vn=async e=>{if(window.crypto.subtle===void 0)return console.warn(`Encryption is not supported in this environment. SSL is required.`),Promise.resolve();let t=await window.crypto.subtle.exportKey(`raw`,e);un.set(fn.key,Array.from(new Uint8Array(t)))},yn=async e=>{if(e)return e;let t=await _n();return t?(await vn(t),t):null},bn=async()=>{let e=un.get(fn.key);return e?await window.crypto.subtle.importKey(`raw`,new Uint8Array(e),{name:`AES-GCM`,length:256},!0,[`encrypt`,`decrypt`]):null},xn=e=>{let t={};for(let n of Object.keys(e))e[n]!==void 0&&(t[n]=e[n]);return t},Sn=(e,t,n)=>{if(e===t)return!0;for(let r in e)if(!n.includes(r)&&e[r]!==t[r]&&!Cn(e[r],t[r]))return!1;for(let r in t)if(!n.includes(r)&&!(r in e))return!1;return!0},Cn=(e,t)=>{switch(typeof e){case`object`:return Sn(e,t,[]);case`function`:return e.toString()===t.toString();default:return e===t}},wn={ms:1,s:1e3,m:1e3*60,h:1e3*60*60,d:1e3*60*60*24},Tn=e=>{if(typeof e==`number`)return e;for(let[t,n]of Object.entries(wn))if(e.endsWith(t))return parseFloat(e)*n;return parseInt(e)},En=new class{cached=[];inFlightRequests=[];removalTimers=[];currentUseId=null;add(e,t,{cacheFor:n,cacheTags:r}){if(this.findInFlight(e))return Promise.resolve();let i=this.findCached(e);if(!e.fresh&&i&&i.staleTimestamp>Date.now())return Promise.resolve();let[a,o]=this.extractStaleValues(n),s=new Promise((n,r)=>{t({...e,onCancel:()=>{this.remove(e),e.onCancel(),r()},onError:t=>{this.remove(e),e.onError(t),r()},onPrefetching(t){e.onPrefetching(t)},onPrefetched(t,n){e.onPrefetched(t,n)},onPrefetchResponse(e){n(e)},onPrefetchError(t){En.removeFromInFlight(e),r(t)}})}).then(t=>{this.remove(e);let n=t.getPageResponse();L.mergeOncePropsIntoResponse(n),this.cached.push({params:{...e},staleTimestamp:Date.now()+a,expiresAt:Date.now()+o,response:s,singleUse:o===0,timestamp:Date.now(),inFlight:!1,tags:Array.isArray(r)?r:[r]});let i=this.getShortestOncePropTtl(n);return this.scheduleForRemoval(e,i?Math.min(o,i):o),this.removeFromInFlight(e),t.handlePrefetch(),t});return this.inFlightRequests.push({params:{...e},response:s,staleTimestamp:null,inFlight:!0}),s}removeAll(){this.cached=[],this.removalTimers.forEach(e=>{clearTimeout(e.timer)}),this.removalTimers=[]}removeByTags(e){this.cached=this.cached.filter(t=>!t.tags.some(t=>e.includes(t)))}remove(e){this.cached=this.cached.filter(t=>!this.paramsAreEqual(t.params,e)),this.clearTimer(e)}removeFromInFlight(e){this.inFlightRequests=this.inFlightRequests.filter(t=>!this.paramsAreEqual(t.params,e))}extractStaleValues(e){let[t,n]=this.cacheForToStaleAndExpires(e);return[Tn(t),Tn(n)]}cacheForToStaleAndExpires(e){if(!Array.isArray(e))return[e,e];switch(e.length){case 0:return[0,0];case 1:return[e[0],e[0]];default:return[e[0],e[1]]}}clearTimer(e){let t=this.removalTimers.find(t=>this.paramsAreEqual(t.params,e));t&&(clearTimeout(t.timer),this.removalTimers=this.removalTimers.filter(e=>e!==t))}scheduleForRemoval(e,t){if(!(typeof window>`u`)&&(this.clearTimer(e),t>0)){let n=window.setTimeout(()=>this.remove(e),t);this.removalTimers.push({params:e,timer:n})}}get(e){return this.findCached(e)||this.findInFlight(e)}use(e,t){let n=`${t.url.pathname}-${Date.now()}-${Math.random().toString(36).substring(7)}`;this.currentUseId=n;let r={...t,cached:!0};return e.response.then(e=>{if(this.currentUseId===n)return e.mergeParams({...r,onPrefetched:()=>{}}),this.removeSingleUseItems(t),e.handle()})}removeSingleUseItems(e){this.cached=this.cached.filter(t=>this.paramsAreEqual(t.params,e)?!t.singleUse:!0)}findCached(e){return this.cached.find(t=>this.paramsAreEqual(t.params,e))||null}findInFlight(e){return this.inFlightRequests.find(t=>this.paramsAreEqual(t.params,e))||null}withoutPurposePrefetchHeader(e){let t=F(e);return t.headers.Purpose===`prefetch`&&delete t.headers.Purpose,t}paramsAreEqual(e,t){return Sn(this.withoutPurposePrefetchHeader(e),this.withoutPurposePrefetchHeader(t),[`id`,`showProgress`,`replace`,`prefetch`,`preserveScroll`,`preserveState`,`onBefore`,`onBeforeUpdate`,`onStart`,`onProgress`,`onFinish`,`onCancel`,`onSuccess`,`onError`,`onFlash`,`onPrefetched`,`onCancelToken`,`onPrefetching`,`async`,`viewTransition`,`optimistic`,`component`,`pageProps`,`cached`])}updateCachedOncePropsFromCurrentPage(){this.cached.forEach(e=>{e.response.then(t=>{let n=t.getPageResponse();L.mergeOncePropsIntoResponse(n,{force:!0});for(let[e,t]of Object.entries(n.deferredProps??{})){let r=t.filter(e=>I(n.props,e)===void 0);r.length>0?n.deferredProps[e]=r:delete n.deferredProps[e]}let r=this.getShortestOncePropTtl(n);if(r===null)return;let i=e.expiresAt-Date.now(),a=Math.min(i,r);a>0?this.scheduleForRemoval(e.params,a):this.remove(e.params)})})}getShortestOncePropTtl(e){let t=Object.values(e.onceProps??{}).map(e=>e.expiresAt).filter(e=>!!e);return t.length===0?null:Math.min(...t)-Date.now()}},Dn=e=>{if(e.offsetParent===null)return!1;let t=e.getBoundingClientRect(),n=t.top=0,r=t.left=0;return n&&r},On=e=>{let t=e=>{let t=window.getComputedStyle(e);return t.overflowY===`scroll`?!0:t.overflowY===`auto`?[`visible`,`clip`].includes(t.overflowX)?!0:r(t.maxHeight,e.style.height)||i(e,`height`):!1},n=e=>{let t=window.getComputedStyle(e);return t.overflowX===`scroll`?!0:t.overflowX===`auto`?[`visible`,`clip`].includes(t.overflowY)?!0:r(t.maxWidth,e.style.width)||i(e,`width`):!1},r=(e,t)=>!!(e&&e!==`none`&&e!==`0px`||t&&t!==`auto`&&t!==`0`),i=(e,t)=>{let n=e.parentElement;if(!n)return!1;let r=window.getComputedStyle(n);if([`flex`,`inline-flex`].includes(r.display)){let e=[`column`,`column-reverse`].includes(r.flexDirection);return t===`height`?e:!e}return[`grid`,`inline-grid`].includes(r.display)},a=e?.parentElement;for(;a;){let e=t(a)||n(a);if(window.getComputedStyle(a).display!==`contents`&&e)return a;a=a.parentElement}return null},kn=(e,t)=>{if(!t)return e.filter(e=>Dn(e));let n=e.indexOf(t),r=[],i=[];for(let t=n;t>=0;t--){let n=e[t];if(Dn(n))r.push(n);else break}for(let t=n+1;t{window.requestAnimationFrame(()=>{t>1?An(e,t-1):e()})},jn=e=>{if(typeof window>`u`)return null;let t=document.querySelector(`script[data-page="${e}"][type="application/json"]`);return t?.textContent?JSON.parse(t.textContent):null},Mn=typeof window>`u`,Nn=!Mn&&/Firefox/i.test(window.navigator.userAgent),Pn=class{static save(){R.saveScrollPositions(this.getScrollRegions())}static getScrollRegions(){return Array.from(this.regions()).map(e=>({top:e.scrollTop,left:e.scrollLeft}))}static regions(){return document.querySelectorAll(`[scroll-region]`)}static scrollToTop(){if(Nn&&getComputedStyle(document.documentElement).scrollBehavior===`smooth`)return An(()=>window.scrollTo(0,0),2);window.scrollTo(0,0)}static reset(){!Mn&&window.location.hash||this.scrollToTop(),this.regions().forEach(e=>{typeof e.scrollTo==`function`?e.scrollTo(0,0):(e.scrollTop=0,e.scrollLeft=0)}),this.save(),this.scrollToAnchor()}static scrollToAnchor(){let e=Mn?null:window.location.hash;e&&setTimeout(()=>{let t=document.getElementById(e.slice(1));t?t.scrollIntoView():this.scrollToTop()})}static restore(e){Mn||window.requestAnimationFrame(()=>{this.restoreDocument(),this.restoreScrollRegions(e)})}static restoreScrollRegions(e){Mn||this.regions().forEach((t,n)=>{let r=e[n];r&&(typeof t.scrollTo==`function`?t.scrollTo(r.left,r.top):(t.scrollTop=r.top,t.scrollLeft=r.left))})}static restoreDocument(){let e=R.getDocumentScrollPosition();window.scrollTo(e.left,e.top)}static onScroll(e){let t=e.target;typeof t.hasAttribute==`function`&&t.hasAttribute(`scroll-region`)&&this.save()}static onWindowScroll(){R.saveDocumentScrollPosition({top:window.scrollY,left:window.scrollX})}},Fn=e=>typeof File<`u`&&e instanceof File||e instanceof Blob||typeof FileList<`u`&&e instanceof FileList&&e.length>0;function In(e){return Fn(e)||e instanceof FormData&&Array.from(e.values()).some(e=>In(e))||typeof e==`object`&&!!e&&Object.values(e).some(e=>In(e))}var Ln=e=>e instanceof FormData;function Rn(e,t=new FormData,n=null,r=`brackets`){e||={};for(let i in e)Object.prototype.hasOwnProperty.call(e,i)&&Bn(t,zn(n,i,`indices`),e[i],r);return t}function zn(e,t,n){return e?n===`brackets`?`${e}[]`:`${e}[${t}]`:t}function Bn(e,t,n,r){if(Array.isArray(n))return Array.from(n.keys()).forEach(i=>Bn(e,zn(t,i.toString(),r),n[i],r));if(n instanceof Date)return e.append(t,n.toISOString());if(n instanceof File)return e.append(t,n,n.name);if(n instanceof Blob)return e.append(t,n);if(typeof n==`boolean`)return e.append(t,n?`1`:`0`);if(typeof n==`string`)return e.append(t,n);if(typeof n==`number`)return e.append(t,`${n}`);if(n==null)return e.append(t,``);Rn(n,e,t,r)}function Vn(e){return/\[\d+\]/.test(decodeURIComponent(e.search))}function Hn(e){if(!e||e===`?`)return{};let t={};return e.replace(/^\?/,``).split(`&`).filter(Boolean).forEach(e=>{let[n,r]=Wn(e);Kn(t,Gn(n),Gn(r))}),t}function Un(e,t){let n=[];return Jn(e,``,n,t),n.length?`?`+n.join(`&`):``}function Wn(e){let t=e.indexOf(`=`);return t===-1?[e,``]:[e.substring(0,t),e.substring(t+1)]}function Gn(e){return decodeURIComponent(e.replace(/\+/g,` `))}function Kn(e,t,n){let r=qn(t);if(r.some(e=>e===`__proto__`))return;let i=e;for(;r.length>1;){let e=r.shift(),t=r[0]===``;(typeof i[e]!=`object`||i[e]===null)&&(i[e]=t?[]:{}),i=i[e]}let a=r.shift();a===``&&Array.isArray(i)?i.push(n):i[a]=n}function qn(e){let t=[],n=e.split(`[`)[0];n&&t.push(n);let r,i=/\[([^\]]*)\]/g;for(;(r=i.exec(e))!==null;)t.push(r[1]);return t}function Jn(e,t,n,r){if(e!==void 0){if(e===null){n.push(`${t}=`);return}if(Array.isArray(e)){e.forEach((e,i)=>{Jn(e,r===`indices`?`${t}[${i}]`:`${t}[]`,n,r)});return}if(typeof e==`object`){Object.keys(e).forEach(i=>{Jn(e[i],t?`${t}[${i}]`:i,n,r)});return}n.push(`${t}=${encodeURIComponent(String(e))}`)}}function Yn(e){return new URL(e.toString(),typeof window>`u`?void 0:window.location.toString())}var Xn=(e,t,n,r,i)=>{let a=typeof e==`string`?Yn(e):e;if((In(t)||r)&&!Ln(t)&&(Kt.get(`form.forceIndicesArrayFormatInFormData`)&&(i=`indices`),t=Rn(t,new FormData,null,i)),Ln(t))return[a,t];let[o,s]=Zn(n,a,t,i);return[Yn(o),s]};function Zn(e,t,n,r=`brackets`){let i=e===`get`&&!Ln(n)&&Object.keys(n).length>0,a=ir(t.toString()),o=a||t.toString().startsWith(`/`)||t.toString()===``,s=!o&&!t.toString().startsWith(`#`)&&!t.toString().startsWith(`?`),c=/^[.]{1,2}([/]|$)/.test(t.toString()),l=t.toString().includes(`?`)||i,u=t.toString().includes(`#`),d=new URL(t.toString(),typeof window>`u`?`http://localhost`:window.location.toString());if(i){let e=Vn(d)?`indices`:r;d.search=Un({...Hn(d.search),...n},e)}return[[a?`${d.protocol}//${d.host}`:``,o?d.pathname:``,s?d.pathname.substring(+!c):``,l?d.search:``,u?d.hash:``].join(``),i?{}:n]}function Qn(e){return e=new URL(e.href),e.hash=``,e}var $n=(e,t)=>{e.hash&&!t.hash&&Qn(e).href===t.href&&(t.hash=e.hash)},er=(e,t)=>Qn(e).href===Qn(t).href,tr=(e,t)=>e.origin===t.origin&&e.pathname===t.pathname;function nr(e){return typeof e==`object`&&!!e&&e!==void 0&&`url`in e&&`method`in e}function rr(e){return e.component?typeof e.component==`string`?e.component:(console.error(`The "component" property on the URL method pair received multiple components (${Object.keys(e.component).join(`, `)}), but only a single component string is supported for instant visits. Use the withComponent() method to specify which component to use.`),null):null}function ir(e){return/^([a-z][a-z0-9+.-]*:)?\/\/[^/]/i.test(e)}function ar(e,t){let n=typeof e==`string`?Yn(e):e;return t?`${n.protocol}//${n.host}${n.pathname}${n.search}${n.hash}`:`${n.pathname}${n.search}${n.hash}`}var L=new class{page;swapComponent;resolveComponent;onFlashCallback;componentId={};listeners=[];isFirstPageLoad=!0;cleared=!1;pendingDeferredProps=null;historyQuotaExceeded=!1;optimisticBaseline={};pendingOptimistics=[];optimisticCounter=0;init({initialPage:e,swapComponent:t,resolveComponent:n,onFlash:r}){return this.page={...e,flash:e.flash??{},rescuedProps:e.rescuedProps??[]},this.swapComponent=t,this.resolveComponent=n,this.onFlashCallback=r,dr.on(`historyQuotaExceeded`,()=>{this.historyQuotaExceeded=!0}),this}set(e,{replace:t=!1,preserveScroll:n=!1,preserveState:r=!1,viewTransition:i=!1,cached:a=!1,visitId:o}={}){Object.keys(e.deferredProps||{}).length&&(this.pendingDeferredProps={deferredProps:e.deferredProps,component:e.component,url:e.url},e.initialDeferredProps===void 0&&(e.initialDeferredProps=e.deferredProps)),this.componentId={};let s=this.componentId;return e.clearHistory&&R.clear(),this.resolve(e.component,e).then(c=>{if(s!==this.componentId)return;e.rememberedState??={};let l=typeof window>`u`,u=l?new URL(e.url):window.location,d=!l&&n?Pn.getScrollRegions():[];t||=er(Yn(e.url),u);let f={...e,flash:{}};return new Promise(e=>t?R.replaceState(f,e):R.pushState(f,e)).then(()=>{let s=!this.isTheSame(e);if(!s&&Object.keys(e.props.errors||{}).length>0&&(i=!1),this.page=e,this.cleared=!1,this.hasOnceProps()&&En.updateCachedOncePropsFromCurrentPage(),s&&this.fireEventsFor(`newComponent`),this.isFirstPageLoad&&this.fireEventsFor(`firstLoad`),this.isFirstPageLoad=!1,this.historyQuotaExceeded){this.historyQuotaExceeded=!1;return}return this.swap({component:c,page:e,preserveState:r,viewTransition:i}).then(()=>{n?window.requestAnimationFrame(()=>Pn.restoreScrollRegions(d)):Pn.reset(),this.pendingDeferredProps&&this.pendingDeferredProps.component===e.component&&this.pendingDeferredProps.url===e.url&&dr.fireInternalEvent(`loadDeferredProps`,this.pendingDeferredProps.deferredProps),this.pendingDeferredProps=null,t||tn(e,{cached:a,visitId:o})})})})}setQuietly(e,{preserveState:t=!1}={}){return this.resolve(e.component,e).then(n=>(this.page=e,this.cleared=!1,R.setCurrent(e),this.swap({component:n,page:e,preserveState:t,viewTransition:!1})))}clear(){this.cleared=!0}isCleared(){return this.cleared}get(){return this.page}getWithoutFlashData(){return{...this.page,flash:{}}}hasOnceProps(){return Object.keys(this.page.onceProps??{}).length>0}merge(e){this.page={...this.page,...e}}setPropsQuietly(e){return this.page={...this.page,props:e},this.resolve(this.page.component,this.page).then(e=>this.swap({component:e,page:this.page,preserveState:!0,viewTransition:!1}))}setFlash(e){this.page={...this.page,flash:e},this.onFlashCallback?.(e)}setUrlHash(e){this.page.url.includes(e)||(this.page.url+=e)}remember(e){this.page.rememberedState=e}swap({component:e,page:t,preserveState:n,viewTransition:r}){let i=()=>this.swapComponent({component:e,page:t,preserveState:n});if(!r||!document?.startViewTransition||document.visibilityState===`hidden`)return i();let a=typeof r==`boolean`?()=>null:r;return new Promise(e=>{a(document.startViewTransition(()=>i().then(e)))})}resolve(e,t){return Promise.resolve(this.resolveComponent(e,t))}nextOptimisticId(){return++this.optimisticCounter}setBaseline(e,t){e in this.optimisticBaseline||(this.optimisticBaseline[e]=t)}updateBaseline(e,t){e in this.optimisticBaseline&&(this.optimisticBaseline[e]=t)}hasBaseline(e){return e in this.optimisticBaseline}registerOptimistic(e,t){this.pendingOptimistics.push({id:e,callback:t})}unregisterOptimistic(e){this.pendingOptimistics=this.pendingOptimistics.filter(t=>t.id!==e)}replayOptimistics(){let e=Object.keys(this.optimisticBaseline);if(e.length===0)return{};let t=F(this.page.props);for(let n of e)t[n]=F(this.optimisticBaseline[n]);for(let{callback:e}of this.pendingOptimistics){let n=e(F(t));n&&Object.assign(t,n)}let n={};for(let r of e)n[r]=t[r];return n}pendingOptimisticCount(){return this.pendingOptimistics.length}clearOptimisticState(){this.optimisticBaseline={},this.pendingOptimistics=[]}isTheSame(e){return this.page.component===e.component}on(e,t){return this.listeners.push({event:e,callback:t}),()=>{this.listeners=this.listeners.filter(n=>n.event!==e&&n.callback!==t)}}fireEventsFor(e){this.listeners.filter(t=>t.event===e).forEach(e=>e.callback())}mergeOncePropsIntoResponse(e,{force:t=!1}={}){Object.entries(e.onceProps??{}).forEach(([n,r])=>{let i=this.page.onceProps?.[n];i!==void 0&&(t||I(e.props,r.prop)===void 0)&&(et(e.props,r.prop,I(this.page.props,i.prop)),e.onceProps[n].expiresAt=i.expiresAt)})}},or=class{items=[];processingPromise=null;add(e){return this.items.push(e),this.process()}process(){return this.processingPromise??=this.processNext().finally(()=>{this.processingPromise=null}),this.processingPromise}processNext(){let e=this.items.shift();return e?Promise.resolve(e()).then(()=>this.processNext()):Promise.resolve()}},sr=typeof window>`u`,cr=new or,lr=!sr&&/CriOS/.test(window.navigator.userAgent),ur=class{rememberedState=`rememberedState`;scrollRegions=`scrollRegions`;preserveUrl=!1;current={};initialState=null;remember(e,t){this.replaceState({...L.getWithoutFlashData(),rememberedState:{...L.get()?.rememberedState??{},[t]:e}})}restore(e){if(!sr)return this.current[this.rememberedState]?.[e]===void 0?this.initialState?.[this.rememberedState]?.[e]:this.current[this.rememberedState]?.[e]}pushState(e,t=null){if(!sr){if(this.preserveUrl){t&&t();return}this.current=e,cr.add(()=>this.getPageData(e).then(n=>{let r=()=>this.doPushState({page:n},e.url).then(()=>t?.());return lr?new Promise(e=>{setTimeout(()=>r().then(e))}):r()}))}}clonePageProps(e){try{return structuredClone(e.props),e}catch{return{...e,props:F(e.props)}}}getPageData(e){let t=this.clonePageProps(e);return new Promise(n=>e.encryptHistory?dn(t).then(n):n(t))}processQueue(){return cr.process()}decrypt(e=null){if(sr)return Promise.resolve(e??L.get());let t=e??window.history.state?.page;return this.decryptPageData(t).then(e=>{if(!e)throw Error(`Unable to decrypt history`);return this.initialState===null?this.initialState=e??void 0:this.current=e??{},e})}decryptPageData(e){return e instanceof ArrayBuffer?pn(e):Promise.resolve(e)}saveScrollPositions(e){cr.add(()=>Promise.resolve().then(()=>{if(window.history.state?.page&&!Me(this.getScrollRegions(),e))return this.doReplaceState({page:window.history.state.page,scrollRegions:e})}))}saveDocumentScrollPosition(e){cr.add(()=>Promise.resolve().then(()=>{if(window.history.state?.page&&!Me(this.getDocumentScrollPosition(),e))return this.doReplaceState({page:window.history.state.page,documentScrollPosition:e})}))}getScrollRegions(){return window.history.state?.scrollRegions||[]}getDocumentScrollPosition(){return window.history.state?.documentScrollPosition||{top:0,left:0}}replaceState(e,t=null){if(Me(this.current,e)){t&&t();return}let{flash:n,...r}=e;if(L.merge(r),!sr){if(this.preserveUrl){t&&t();return}this.current=e,cr.add(()=>this.getPageData(e).then(n=>{let r=()=>this.doReplaceState({page:n},e.url).then(()=>t?.());return lr?new Promise(e=>{setTimeout(()=>r().then(e))}):r()}))}}isHistoryThrottleError(e){return e instanceof Error&&e.name===`SecurityError`&&(e.message.includes(`history.pushState`)||e.message.includes(`history.replaceState`))}isQuotaExceededError(e){return e instanceof Error&&e.name===`QuotaExceededError`}withThrottleProtection(e){return Promise.resolve().then(()=>{try{return e()}catch(e){if(!this.isHistoryThrottleError(e))throw e;console.error(e.message)}})}doReplaceState(e,t){return this.withThrottleProtection(()=>{window.history.replaceState({...e,scrollRegions:e.scrollRegions??window.history.state?.scrollRegions,documentScrollPosition:e.documentScrollPosition??window.history.state?.documentScrollPosition},``,t)})}doPushState(e,t){return this.withThrottleProtection(()=>{try{window.history.pushState(e,``,t)}catch(e){if(!this.isQuotaExceededError(e))throw e;dr.fireInternalEvent(`historyQuotaExceeded`,t)}})}getState(e,t){return this.current?.[e]??t}deleteState(e){this.current[e]!==void 0&&(delete this.current[e],this.replaceState(this.current))}clearInitialState(e){this.initialState&&this.initialState[e]!==void 0&&delete this.initialState[e]}browserHasHistoryEntry(){return!sr&&!!window.history.state?.page}clear(){un.remove(fn.key),un.remove(fn.iv)}setCurrent(e){this.current=e}isValidState(e){return!!e.page}getAllState(){return this.current}};typeof window<`u`&&window.history.scrollRestoration&&(window.history.scrollRestoration=`manual`);var R=new ur,dr=new class{internalListeners=[];init(){typeof window<`u`&&(window.addEventListener(`popstate`,this.handlePopstateEvent.bind(this)),window.addEventListener(`pageshow`,this.handlePageshowEvent.bind(this)),window.addEventListener(`scroll`,qt(Pn.onWindowScroll.bind(Pn),100),!0)),typeof document<`u`&&document.addEventListener(`scroll`,qt(Pn.onScroll.bind(Pn),100),!0)}onGlobalEvent(e,t){return this.registerListener(`inertia:${e}`,(e=>{let n=t(e);e.cancelable&&!e.defaultPrevented&&n===!1&&e.preventDefault()}))}on(e,t){return this.internalListeners.push({event:e,listener:t}),()=>{this.internalListeners=this.internalListeners.filter(e=>e.listener!==t)}}onMissingHistoryItem(){L.clear(),this.fireInternalEvent(`missingHistoryItem`)}fireInternalEvent(e,...t){this.internalListeners.filter(t=>t.event===e).forEach(e=>e.listener(...t))}registerListener(e,t){return document.addEventListener(e,t),()=>document.removeEventListener(e,t)}handlePageshowEvent(e){e.persisted&&R.decrypt().catch(()=>this.onMissingHistoryItem())}handlePopstateEvent(e){let t=e.state||null;if(t===null){let e=Yn(L.get().url);e.hash=window.location.hash,R.replaceState({...L.getWithoutFlashData(),url:e.href}),Pn.reset();return}if(!R.isValidState(t))return this.onMissingHistoryItem();R.decrypt(t.page).then(e=>{if(L.get().version!==e.version){this.onMissingHistoryItem();return}H.cancelAll({prefetch:!1}),L.setQuietly(e,{preserveState:!1}).then(()=>{Pn.restore(R.getScrollRegions()),tn(L.get());let t={},n=L.get().props;for(let[r,i]of Object.entries(e.initialDeferredProps??e.deferredProps??{})){let e=i.filter(e=>I(n,e)===void 0);e.length>0&&(t[r]=e)}Object.keys(t).length>0&&this.fireInternalEvent(`loadDeferredProps`,t)})}).catch(()=>{this.onMissingHistoryItem()})}},fr=new class{type;constructor(){this.type=this.resolveType()}resolveType(){return typeof window>`u`?`navigate`:window.performance?.getEntriesByType(`navigation`)[0]?.type??`navigate`}get(){return this.type}isBackForward(){return this.type===`back_forward`}isReload(){return this.type===`reload`}};function pr(){let e=typeof window<`u`?window.crypto:void 0;if(e?.randomUUID)return e.randomUUID();let t=()=>e?.getRandomValues?e.getRandomValues(new Uint8Array(1))[0]:Math.floor(Math.random()*256);return`10000000-1000-4000-8000-100000000000`.replace(/[018]/g,e=>(e^t()&15>>e/4).toString(16))}var mr=class{static handle(){this.clearRememberedStateOnReload(),[this.handleBackForward,this.handleLocation,this.handleDefault].find(e=>e.bind(this)())}static clearRememberedStateOnReload(){fr.isReload()&&(R.deleteState(R.rememberedState),R.clearInitialState(R.rememberedState))}static handleBackForward(){if(!fr.isBackForward()||!R.browserHasHistoryEntry())return!1;let e=R.getScrollRegions();return R.decrypt().then(t=>{let n=pr();L.set(t,{preserveScroll:!0,preserveState:!0,visitId:n}).then(()=>{Pn.restore(e),tn(L.get(),{visitId:n})})}).catch(()=>{dr.onMissingHistoryItem()}),!0}static handleLocation(){if(!un.exists(un.locationVisitKey))return!1;let e=un.get(un.locationVisitKey)||{};return un.remove(un.locationVisitKey),typeof window<`u`&&L.setUrlHash(window.location.hash),R.decrypt(L.get()).then(()=>{let t=pr(),n=R.getState(R.rememberedState,{}),r=R.getScrollRegions();L.remember(n),L.set(L.get(),{preserveScroll:e.preserveScroll,preserveState:!0,visitId:t}).then(()=>{e.preserveScroll&&Pn.restore(r),this.fireInitialEvents(t)})}).catch(()=>{dr.onMissingHistoryItem()}),!0}static handleDefault(){typeof window<`u`&&L.setUrlHash(window.location.hash);let e=pr();L.set(L.get(),{preserveScroll:!0,preserveState:!0,visitId:e}).then(()=>{fr.isReload()?Pn.restore(R.getScrollRegions()):Pn.scrollToAnchor(),this.fireInitialEvents(e)})}static fireInitialEvents(e){let t=L.get();tn(t,{visitId:e}),Object.keys(t.flash).length>0&&queueMicrotask(()=>ln(t.flash))}},hr=class{intervalId=null;timeoutId=null;throttle=!1;keepAlive=!1;cb;interval;cbCount=0;mode;inFlight=!1;currentCancel=null;stopped=!0;instanceId=0;constructor(e,t,n){this.keepAlive=n.keepAlive??!1,this.mode=n.mode??`overlap`,this.cb=t,this.interval=e,(n.autoStart??!0)&&this.start()}stop(){this.stopped=!0,this.instanceId++,this.inFlight=!1,this.currentCancel=null,this.intervalId&&=(clearInterval(this.intervalId),null),this.timeoutId&&=(clearTimeout(this.timeoutId),null)}start(){if(!(typeof window>`u`)){if(this.stop(),this.stopped=!1,this.mode===`rest`){this.scheduleNext();return}this.intervalId=window.setInterval(()=>this.tick(),this.interval)}}isInBackground(e){this.throttle=this.keepAlive?!1:e,this.throttle&&(this.cbCount=0)}scheduleNext(){this.stopped||(this.timeoutId=window.setTimeout(()=>{this.timeoutId=null,this.tick()},this.interval))}tick(){!this.throttle||this.cbCount%10==0?this.fire():this.mode===`rest`&&this.scheduleNext(),this.throttle&&this.cbCount++}fire(){this.inFlight&&this.mode===`cancel`&&this.currentCancel?.();let e=this.instanceId;this.cb({onStart:t=>{e===this.instanceId&&(this.inFlight=!0,this.currentCancel=t)},onFinish:()=>{e===this.instanceId&&(this.inFlight=!1,this.currentCancel=null,this.mode===`rest`&&this.scheduleNext())}})}},gr=new class{polls=[];constructor(){this.setupVisibilityListener()}get count(){return this.polls.length}add(e,t,n){let r=new hr(e,t,n);return this.polls.push(r),{stop:()=>r.stop(),start:()=>r.start(),destroy:()=>{r.stop(),this.polls=this.polls.filter(e=>e!==r)}}}clear(){this.polls.forEach(e=>e.stop()),this.polls=[]}setupVisibilityListener(){typeof document>`u`||document.addEventListener(`visibilitychange`,()=>{this.polls.forEach(e=>e.isInBackground(document.hidden))},!1)}},_r=new class{requestHandlers=[];responseHandlers=[];errorHandlers=[];onRequest(e){return this.requestHandlers.push(e),()=>{this.requestHandlers=this.requestHandlers.filter(t=>t!==e)}}onResponse(e){return this.responseHandlers.push(e),()=>{this.responseHandlers=this.responseHandlers.filter(t=>t!==e)}}onError(e){return this.errorHandlers.push(e),()=>{this.errorHandlers=this.errorHandlers.filter(t=>t!==e)}}async processRequest(e){let t=e;for(let e of this.requestHandlers)t=await e(t);return t}async processResponse(e){let t=e;for(let e of this.responseHandlers)t=await e(t);return t}async processError(e){for(let t of this.errorHandlers)await t(e)}},vr=class extends Error{code;url;constructor(e,t,n){super(n?`${e} (${n})`:e),this.name=`HttpError`,this.code=t,this.url=n}},yr=class extends vr{response;constructor(e,t,n){super(e,`ERR_HTTP_RESPONSE`,n),this.name=`HttpResponseError`,this.response=t}},br=class extends vr{constructor(e=`Request was cancelled`,t){super(e,`ERR_CANCELLED`,t),this.name=`HttpCancelledError`}},xr=class extends vr{cause;constructor(e,t,n){super(e,`ERR_NETWORK`,t),this.name=`HttpNetworkError`,this.cause=n}};function Sr(e){let t=document.cookie.match(RegExp(`(^|;\\s*)(`+e+`)=([^;]*)`));return t?decodeURIComponent(t[3]):null}function Cr(e){let t={};return e.getAllResponseHeaders().split(`\r +`).forEach(e=>{let n=e.indexOf(`:`);n>0&&(t[e.slice(0,n).toLowerCase().trim()]=e.slice(n+1).trim())}),t}function wr(e,t){if(!t.headers)return;let n=t.data instanceof FormData;Object.entries(t.headers).forEach(([t,r])=>{(t.toLowerCase()!==`content-type`||!n)&&e.setRequestHeader(t,String(r))})}function Tr(e,t){if(!t||Object.keys(t).length===0)return e;let[n]=Zn(`get`,e,t);return n}var Er=class{xsrfCookieName;xsrfHeaderName;constructor(e={}){this.xsrfCookieName=e.xsrfCookieName??`XSRF-TOKEN`,this.xsrfHeaderName=e.xsrfHeaderName??`X-XSRF-TOKEN`}async request(e){let t=await _r.processRequest(e);try{let e=await this.doRequest(t);return await _r.processResponse(e)}catch(e){throw(e instanceof yr||e instanceof xr||e instanceof br)&&await _r.processError(e),e}}doRequest(e){return new Promise((t,n)=>{let r=new XMLHttpRequest,i=Tr(e.url,e.params);r.open(e.method.toUpperCase(),i,!0);let a=Sr(this.xsrfCookieName);a&&r.setRequestHeader(this.xsrfHeaderName,a);let o=null;e.data!==null&&e.data!==void 0&&(e.data instanceof FormData?o=e.data:typeof e.data==`object`?(o=JSON.stringify(e.data),!e.headers?.[`Content-Type`]&&!e.headers?.[`content-type`]&&r.setRequestHeader(`Content-Type`,`application/json`)):o=String(e.data)),wr(r,e),e.onUploadProgress&&(r.upload.onprogress=t=>{let n=t.lengthComputable?t.loaded/t.total:void 0;e.onUploadProgress({progress:n,percentage:n?Math.round(n*100):0,loaded:t.loaded,total:t.lengthComputable?t.total:void 0})}),e.signal&&e.signal.addEventListener(`abort`,()=>r.abort()),r.onabort=()=>n(new br(`Request was cancelled`,e.url)),r.onerror=()=>n(new xr(`Network error`,e.url)),r.onload=()=>{let i={status:r.status,data:r.responseText,headers:Cr(r)};r.status>=400?n(new yr(`Request failed with status ${r.status}`,i,e.url)):t(i)},r.send(o)})}},Dr=new Er;function Or(e){return!(`request`in e)}var kr={getClient(){return Dr},setClient(e){if(!Or(e)){Dr=e;return}Dr=new Er(e),e.xsrfCookieName&&Dt.withXsrfCookieName(e.xsrfCookieName),e.xsrfHeaderName&&Dt.withXsrfHeaderName(e.xsrfHeaderName)},onRequest:_r.onRequest.bind(_r),onResponse:_r.onResponse.bind(_r),onError:_r.onError.bind(_r),processRequest:_r.processRequest.bind(_r),processResponse:_r.processResponse.bind(_r),processError:_r.processError.bind(_r)},Ar=new class{requestHandlers=[];responseHandlers=[];onVisitRequest(e){return this.requestHandlers.push(e),()=>{this.requestHandlers=this.requestHandlers.filter(t=>t!==e)}}onVisitResponse(e){return this.responseHandlers.push(e),()=>{this.responseHandlers=this.responseHandlers.filter(t=>t!==e)}}async processRequest(e,t){let n=t;for(let t of this.requestHandlers)n=await t(e,n);return n}async processResponse(e,t){let n=t;for(let t of this.responseHandlers)n=await t(e,n);return n}};function jr(){typeof window>`u`||(window.__inertia_interceptors__=Ar)}var Mr=class e{callbacks=[];params;constructor(e){if(!e.prefetch)this.params=e;else{let t={onBefore:this.wrapCallback(e,`onBefore`),onBeforeUpdate:this.wrapCallback(e,`onBeforeUpdate`),onStart:this.wrapCallback(e,`onStart`),onProgress:this.wrapCallback(e,`onProgress`),onFinish:this.wrapCallback(e,`onFinish`),onCancel:this.wrapCallback(e,`onCancel`),onSuccess:this.wrapCallback(e,`onSuccess`),onError:this.wrapCallback(e,`onError`),onHttpException:this.wrapCallback(e,`onHttpException`),onNetworkError:this.wrapCallback(e,`onNetworkError`),onFlash:this.wrapCallback(e,`onFlash`),onCancelToken:this.wrapCallback(e,`onCancelToken`),onPrefetched:this.wrapCallback(e,`onPrefetched`),onPrefetching:this.wrapCallback(e,`onPrefetching`)};this.params={...e,...t,onPrefetchResponse:e.onPrefetchResponse||(()=>{}),onPrefetchError:e.onPrefetchError||(()=>{})}}}static create(t){return new e(t)}data(){return this.params.method===`get`?null:this.params.data}queryParams(){return this.params.method===`get`?this.params.data:{}}isPartial(){return this.params.only.length>0||this.params.except.length>0||this.params.reset.length>0}isPrefetch(){return this.params.prefetch===!0}isDeferredPropsRequest(){return this.params.deferredProps===!0}onCancelToken(e){this.params.onCancelToken({cancel:e})}markAsFinished(){this.params.completed=!0,this.params.cancelled=!1,this.params.interrupted=!1}markAsCancelled({cancelled:e=!0,interrupted:t=!1}){this.params.onCancel(),this.params.completed=!1,this.params.cancelled=e,this.params.interrupted=t}wasCancelledAtAll(){return this.params.cancelled||this.params.interrupted}onFinish(){this.params.onFinish(this.params)}onStart(){this.params.onStart(this.params)}onPrefetching(){this.params.onPrefetching(this.params)}onPrefetchResponse(e){this.params.onPrefetchResponse&&this.params.onPrefetchResponse(e)}onPrefetchError(e){this.params.onPrefetchError&&this.params.onPrefetchError(e)}all(){return this.params}headers(){let e={...this.params.headers};this.isPartial()&&(e[`X-Inertia-Partial-Component`]=L.get().component);let t=this.params.only.concat(this.params.reset);return t.length>0&&(e[`X-Inertia-Partial-Data`]=t.join(`,`)),this.params.except.length>0&&(e[`X-Inertia-Partial-Except`]=this.params.except.join(`,`)),this.params.reset.length>0&&(e[`X-Inertia-Reset`]=this.params.reset.join(`,`)),this.params.errorBag&&this.params.errorBag.length>0&&(e[`X-Inertia-Error-Bag`]=this.params.errorBag),e}setPreserveOptions(t){this.params.preserveScroll=e.resolvePreserveOption(this.params.preserveScroll,t),this.params.preserveState=e.resolvePreserveOption(this.params.preserveState,t)}runCallbacks(){this.callbacks.forEach(({name:e,args:t})=>{this.params[e](...t)})}merge(e){this.params={...this.params,...e}}wrapCallback(e,t){return(...n)=>{this.recordCallback(t,n),e[t](...n)}}recordCallback(e,t){this.callbacks.push({name:e,args:t})}static resolvePreserveOption(e,t){return typeof e==`function`?e(t):e===`errors`?Object.keys(t.props.errors||{}).length>0:e}},Nr={createIframeAndPage(e){typeof e==`object`&&(e=`All Inertia requests must receive a valid Inertia response, however a plain JSON response was received.
${JSON.stringify(e)}`);let t=document.createElement(`html`);t.innerHTML=e,t.querySelectorAll(`a`).forEach(e=>e.setAttribute(`target`,`_top`));let n=document.createElement(`iframe`);return n.style.backgroundColor=`white`,n.style.borderRadius=`5px`,n.style.width=`100%`,n.style.height=`100%`,n.setAttribute(`sandbox`,`allow-scripts`),{iframe:n,page:t}},show(e){let{iframe:t,page:n}=this.createIframeAndPage(e);t.style.boxSizing=`border-box`,t.style.display=`block`;let r=document.createElement(`dialog`);r.id=`inertia-error-dialog`,Object.assign(r.style,{width:`calc(100vw - 100px)`,height:`calc(100vh - 100px)`,padding:`0`,margin:`auto`,border:`none`,backgroundColor:`transparent`});let i=document.createElement(`style`);i.textContent=` + dialog#inertia-error-dialog::backdrop { + background-color: rgba(0, 0, 0, 0.6); + } + + dialog#inertia-error-dialog:focus { + outline: none; + } + `;let a=Kt.get(`nonce`);a&&(i.nonce=a),document.head.appendChild(i),r.addEventListener(`click`,e=>{e.target===r&&r.close()}),r.addEventListener(`close`,()=>{i.remove(),r.remove()}),r.appendChild(t),document.body.prepend(r),r.showModal(),r.focus(),t.srcdoc=n.outerHTML}},Pr=(e,t)=>e===t||e.startsWith(`${t}.`),Fr=(e,t)=>{let{only:n,except:r}=e;return!(n.length===0&&r.length===0||n.length>0&&!n.some(e=>Pr(t,e))||r.length>0&&r.some(e=>Pr(t,e)))},Ir=(e,t)=>t.some(t=>Fr(e,t)),Lr=new or,Rr=class e{constructor(e,t,n){this.requestParams=e,this.response=t,this.originatingPage=n}wasPrefetched=!1;processed=!1;static create(t,n,r){return new e(t,n,r)}isProcessed(){return this.processed}async handlePrefetch(){er(this.requestParams.all().url,window.location)&&this.handle()}async handle(){return Lr.add(()=>this.process())}async process(){if(this.requestParams.all().prefetch)return this.wasPrefetched=!0,this.requestParams.all().prefetch=!1,this.requestParams.all().onPrefetched(this.response,this.requestParams.all()),sn(this.response,this.requestParams.all()),Promise.resolve();if(this.requestParams.runCallbacks(),this.processed=!0,!this.isInertiaResponse())return this.handleNonInertiaResponse();if(this.isHttpException()){let e={...this.response,data:this.getDataFromResponse(this.response.data)};if(this.requestParams.all().onHttpException(e)===!1||!$t(e))return}await R.processQueue(),R.preserveUrl=this.requestParams.all().preserveUrl,await this.setPage();let{flash:e}=L.get();Object.keys(e).length>0&&!this.requestParams.isDeferredPropsRequest()&&(ln(e),this.requestParams.all().onFlash(e));let t=L.get().props.errors||{};if(Object.keys(t).length>0){let e=this.getScopedErrors(t);return Xt(e,{page:L.get(),visitId:this.requestParams.all().id}),this.requestParams.all().onError(e)}H.flushByCacheTags(this.requestParams.all().invalidateCacheTags||[]),this.wasPrefetched||H.flush(L.get().url),on(L.get(),{visitId:this.requestParams.all().id}),await this.requestParams.all().onSuccess(L.get()),R.preserveUrl=!1}mergeParams(e){this.requestParams.merge(e)}getPageResponse(){let e=this.getDataFromResponse(this.response.data);return typeof e==`object`?this.response.data={...e,flash:e.flash??{},rescuedProps:e.rescuedProps??[]}:this.response.data=e}async handleNonInertiaResponse(){if(this.isInertiaRedirect()){H.visit(this.getHeader(`x-inertia-redirect`),{...this.requestParams.all(),method:`get`,data:{}});return}if(this.isLocationVisit()){let e=Yn(this.getHeader(`x-inertia-location`));return $n(this.requestParams.all().url,e),this.locationVisit(e)}let e={...this.response,data:this.getDataFromResponse(this.response.data)};if(this.requestParams.all().onHttpException(e)!==!1&&$t(e))return Nr.show(e.data)}isInertiaResponse(){return this.hasHeader(`x-inertia`)}isHttpException(){return this.response.status>=400}hasStatus(e){return this.response.status===e}getHeader(e){return this.response.headers[e]}hasHeader(e){return this.getHeader(e)!==void 0}isInertiaRedirect(){return this.hasStatus(409)&&this.hasHeader(`x-inertia-redirect`)}isLocationVisit(){return this.hasStatus(409)&&this.hasHeader(`x-inertia-location`)}locationVisit(e){try{if(un.set(un.locationVisitKey,{preserveScroll:this.requestParams.all().preserveScroll===!0}),typeof window>`u`)return;er(window.location,e)?window.location.reload():window.location.href=e.href}catch{return!1}}async setPage(){let e=this.getPageResponse();return this.shouldSetPage(e)?(this.response=await Ar.processResponse(this.requestParams.all(),this.response),this.mergeProps(e),L.mergeOncePropsIntoResponse(e),this.preserveOptimisticProps(e),this.preserveEqualProps(e),await this.setRememberedState(e),this.requestParams.setPreserveOptions(e),e.url=R.preserveUrl?L.get().url:this.pageUrl(e),this.requestParams.all().onBeforeUpdate(e),en(e),L.set(e,{replace:this.requestParams.all().replace,preserveScroll:this.requestParams.all().preserveScroll,preserveState:this.requestParams.all().preserveState,viewTransition:this.requestParams.all().viewTransition,cached:this.requestParams.all().cached,visitId:this.requestParams.all().id})):Promise.resolve()}getDataFromResponse(e){if(typeof e!=`string`)return e;try{return JSON.parse(e)}catch{return e}}shouldSetPage(e){if(!this.requestParams.all().async||this.originatingPage.component!==e.component)return!0;if(this.originatingPage.component!==L.get().component)return!1;let t=Yn(this.originatingPage.url),n=Yn(L.get().url);return t.origin===n.origin&&t.pathname===n.pathname}pageUrl(e){let t=Yn(e.url);return e.preserveFragment?t.hash=this.requestParams.all().url.hash:$n(this.requestParams.all().url,t),t.pathname+t.search+t.hash}preserveOptimisticProps(e){if(H.hasPendingOptimistic())for(let t of Object.keys(e.props))L.hasBaseline(t)&&(L.updateBaseline(t,e.props[t]),e.props[t]=L.get().props[t])}preserveEqualProps(e){if(e.component!==L.get().component)return;let t=L.get().props;Object.entries(e.props).forEach(([n,r])=>{Me(r,t[n])&&(e.props[n]=t[n])})}mergeProps(e){if(!this.requestParams.isPartial()||e.component!==L.get().component)return;let t=e.mergeProps||[],n=e.prependProps||[],r=e.deepMergeProps||[],i=e.matchPropsOn||[],a=(t,n)=>{let r=I(L.get().props,t),a=I(e.props,t);if(Array.isArray(a)){let o=this.mergeOrMatchItems(r||[],a,t,i,n);et(e.props,t,o)}else if(typeof a==`object`&&a){let n={...r||{},...a};et(e.props,t,n)}};t.forEach(e=>a(e,!0)),n.forEach(e=>a(e,!1)),r.forEach(t=>{let n=I(L.get().props,t),r=I(e.props,t),a=(e,t,n)=>Array.isArray(t)?this.mergeOrMatchItems(e,t,n,i):typeof t==`object`&&t?Object.keys(t).reduce((r,i)=>(r[i]=a(e?e[i]:void 0,t[i],`${n}.${i}`),r),{...e}):t;et(e.props,t,a(n,r,t))});let o=new Set([...this.requestParams.all().only,...this.requestParams.all().except].filter(e=>e.includes(`.`)).map(e=>e.split(`.`)[0]));for(let t of o){let n=L.get().props[t];this.isObject(n)&&this.isObject(e.props[t])&&(e.props[t]=this.deepMergeObjects(n,e.props[t]))}e.props={...L.get().props,...e.props},this.shouldPreserveErrors(e)&&(e.props.errors=L.get().props.errors),L.get().scrollProps&&(e.scrollProps={...L.get().scrollProps||{},...e.scrollProps||{}}),L.hasOnceProps()&&(e.onceProps={...L.get().onceProps||{},...e.onceProps||{}}),this.requestParams.isDeferredPropsRequest()&&(e.flash={...L.get().flash});let s=L.get().initialDeferredProps;s&&Object.keys(s).length>0&&(e.initialDeferredProps=s),e.rescuedProps=this.mergeRescuedProps(e)}mergeRescuedProps(e){let t=L.get().rescuedProps??[],n=e.rescuedProps??[],r=new Set(t.filter(e=>!Fr(this.requestParams.all(),e)));return n.forEach(e=>r.add(e)),Array.from(r)}shouldPreserveErrors(e){if(!this.requestParams.all().preserveErrors)return!1;let t=L.get().props.errors;if(!t||Object.keys(t).length===0)return!1;let n=e.props.errors;return!(n&&Object.keys(n).length>0)}isObject(e){return e&&typeof e==`object`&&!Array.isArray(e)}deepMergeObjects(e,t){let n={...e};for(let r of Object.keys(t)){let i=e[r],a=t[r];this.isObject(i)&&this.isObject(a)?n[r]=this.deepMergeObjects(i,a):n[r]=a}return n}mergeOrMatchItems(e,t,n,r,i=!0){let a=Array.isArray(e)?e:[],o=r.find(e=>e.split(`.`).slice(0,-1).join(`.`)===n);if(!o)return i?[...a,...t]:[...t,...a];let s=o.split(`.`).pop()||``,c=new Map;return t.forEach(e=>{this.hasUniqueProperty(e,s)&&c.set(e[s],e)}),i?this.appendWithMatching(a,t,c,s):this.prependWithMatching(a,t,c,s)}appendWithMatching(e,t,n,r){let i=e.map(e=>this.hasUniqueProperty(e,r)&&n.has(e[r])?n.get(e[r]):e),a=t.filter(t=>this.hasUniqueProperty(t,r)?!e.some(e=>this.hasUniqueProperty(e,r)&&e[r]===t[r]):!0);return[...i,...a]}prependWithMatching(e,t,n,r){let i=e.filter(e=>this.hasUniqueProperty(e,r)?!n.has(e[r]):!0);return[...t,...i]}hasUniqueProperty(e,t){return e&&typeof e==`object`&&t in e}async setRememberedState(e){let t=await R.getState(R.rememberedState,{});this.requestParams.all().preserveState&&t&&e.component===L.get().component&&(e.rememberedState=t)}getScopedErrors(e){return this.requestParams.all().errorBag?e[this.requestParams.all().errorBag||``]||{}:e}},zr=class e{constructor(e,t,{optimistic:n=!1}={}){this.page=t,this.requestParams=Mr.create(e),this.cancelToken=new AbortController,this.optimistic=n}response;cancelToken;requestParams;requestHasFinished=!1;optimistic;static create(t,n,r){return new e(t,n,r)}isPrefetch(){return this.requestParams.isPrefetch()}isOptimistic(){return this.optimistic}isPendingOptimistic(){return this.isOptimistic()&&(!this.response||!this.response.isProcessed())}async send(){this.requestParams.onCancelToken(()=>this.cancel({cancelled:!0})),an(this.requestParams.all()),this.requestParams.onStart(),this.requestParams.all().prefetch&&(this.requestParams.onPrefetching(),cn(this.requestParams.all()));let e=this.requestParams.all().prefetch,t={method:this.requestParams.all().method,url:Qn(this.requestParams.all().url).href,data:this.requestParams.data(),signal:this.cancelToken.signal,headers:this.getHeaders(),onUploadProgress:this.onProgress.bind(this)},n=await Ar.processRequest(this.requestParams.all(),t);return kr.getClient().request(n).then(e=>(this.response=Rr.create(this.requestParams,e,this.page),this.response.handle())).catch(e=>e instanceof yr?(this.response=Rr.create(this.requestParams,e.response,this.page),this.response.handle()):Promise.reject(e)).catch(t=>{if(!(t instanceof br)&&this.requestParams.all().onNetworkError(t)!==!1&&Zt(t))return e&&this.requestParams.onPrefetchError(t),Promise.reject(t)}).finally(()=>{this.finish(),e&&this.response&&this.requestParams.onPrefetchResponse(this.response)})}finish(){this.requestParams.wasCancelledAtAll()||(this.requestParams.markAsFinished(),this.fireFinishEvents())}fireFinishEvents(){this.requestHasFinished||(this.requestHasFinished=!0,Qt(this.requestParams.all()),this.requestParams.onFinish())}cancel({cancelled:e=!1,interrupted:t=!1}){this.requestHasFinished||(this.cancelToken.abort(),this.requestParams.markAsCancelled({cancelled:e,interrupted:t}),this.fireFinishEvents())}onProgress(e){this.requestParams.data()instanceof FormData&&(rn(e),this.requestParams.all().onProgress(e))}getHeaders(){let e={...this.requestParams.headers(),Accept:`text/html, application/xhtml+xml`,"X-Requested-With":`XMLHttpRequest`,"X-Inertia":!0},t=L.get();t.version&&(e[`X-Inertia-Version`]=t.version);let n=Object.entries(t.onceProps||{}).filter(([,e])=>I(t.props,e.prop)===void 0?!1:!e.expiresAt||e.expiresAt>Date.now()).map(([e])=>e);return n.length>0&&(e[`X-Inertia-Except-Once-Props`]=n.join(`,`)),e}},Br=class{requests=[];maxConcurrent;interruptible;constructor({maxConcurrent:e,interruptible:t}){this.maxConcurrent=e,this.interruptible=t}send(e){this.requests.push(e),e.send().finally(()=>{this.requests=this.requests.filter(t=>t!==e)})}interruptInFlight(){this.cancel({interrupted:!0},!1)}cancelInFlight({prefetch:e=!0,optimistic:t=!0}={}){this.requests.filter(t=>e||!t.isPrefetch()).filter(e=>t||!e.isOptimistic()).forEach(e=>e.cancel({cancelled:!0}))}cancel({cancelled:e=!1,interrupted:t=!1}={},n=!1){!n&&!this.shouldCancel()||this.requests.shift()?.cancel({cancelled:e,interrupted:t})}shouldCancel(){return this.interruptible&&this.requests.length>=this.maxConcurrent}hasPendingOptimistic(){return this.requests.some(e=>e.isPendingOptimistic())}},Vr=()=>{},Hr=class{syncRequestStream=new Br({maxConcurrent:1,interruptible:!0});asyncRequestStream=new Br({maxConcurrent:1/0,interruptible:!1});clientVisitQueue=new or;pendingOptimisticCallback=void 0;init({initialPage:e,resolveComponent:t,swapComponent:n,onFlash:r}){L.init({initialPage:e,resolveComponent:t,swapComponent:n,onFlash:r}),mr.handle(),dr.init(),dr.on(`missingHistoryItem`,()=>{typeof window<`u`&&this.visit(window.location.href,{preserveState:!0,preserveScroll:!0,replace:!0})}),dr.on(`loadDeferredProps`,e=>{this.loadDeferredProps(e)}),dr.on(`historyQuotaExceeded`,e=>{window.location.href=e})}optimistic(e){return this.pendingOptimisticCallback=e,this}get(e,t={},n={}){return this.visit(e,{...n,method:`get`,data:t})}post(e,t={},n={}){return this.visit(e,{preserveState:!0,...n,method:`post`,data:t})}put(e,t={},n={}){return this.visit(e,{preserveState:!0,...n,method:`put`,data:t})}patch(e,t={},n={}){return this.visit(e,{preserveState:!0,...n,method:`patch`,data:t})}delete(e,t={}){return this.visit(e,{preserveState:!0,...t,method:`delete`})}reload(e={}){return this.doReload(e)}doReload(e={}){if(!(typeof window>`u`))return this.visit(window.location.href,{...e,preserveScroll:!0,preserveState:!0,async:!0,headers:{...e.headers||{},"Cache-Control":`no-cache`}})}remember(e,t=`default`){R.remember(e,t)}restore(e=`default`){return R.restore(e)}on(e,t){return typeof window>`u`?()=>{}:dr.onGlobalEvent(e,t)}once(e,t){if(typeof window>`u`)return()=>{};let n=this.on(e,e=>(n(),t(e)));return n}hasPendingOptimistic(){return this.asyncRequestStream.hasPendingOptimistic()}get activePolls(){return gr.count}cancelAll({async:e=!0,prefetch:t=!0,sync:n=!0}={}){e&&this.asyncRequestStream.cancelInFlight({prefetch:t}),n&&this.syncRequestStream.cancelInFlight()}poll(e,t={},n={}){return gr.add(e,({onStart:e,onFinish:n})=>{let r=typeof t==`function`?t():t;this.reload({preserveErrors:!0,...r,onCancelToken:t=>{e(t.cancel),r.onCancelToken?.(t)},onFinish:e=>{n(),r.onFinish?.(e)}})},{autoStart:n.autoStart??!0,keepAlive:n.keepAlive??!1,mode:n.mode})}visit(e,t={}){t.optimistic=t.optimistic??this.pendingOptimisticCallback,this.pendingOptimisticCallback=void 0,t.optimistic&&(t.async=t.async??!0);let n=this.getPendingVisit(e,{...t,showProgress:t.showProgress??(!t.async||!!t.optimistic)}),r=this.getVisitEvents(t);if(r.onBefore(n)===!1||!Yt(n))return;let i=Yn(L.get().url);(n.only.length>0||n.except.length>0||n.reset.length>0?tr(n.url,i):er(n.url,i))||this.asyncRequestStream.cancelInFlight({prefetch:!1,optimistic:!1}),n.async||this.syncRequestStream.interruptInFlight(),t.optimistic&&this.applyOptimisticUpdate(t.optimistic,r),!L.isCleared()&&!n.preserveUrl&&Pn.save();let a={...n,...r},o=()=>{let e=En.get(a);e?(Ui.reveal(e.inFlight),En.use(e,a)):(Ui.reveal(!0),(n.async?this.asyncRequestStream:this.syncRequestStream).send(zr.create(a,L.get(),{optimistic:!!t.optimistic})))};Array.isArray(n.component)&&(console.error(`The "component" prop received an array of components (${n.component.join(`, `)}), but only a single component string is supported for instant visits. Pass an explicit component name instead.`),n.component=null),n.component?R.processQueue().then(()=>{this.performInstantSwap(n).then(()=>{a.preserveScroll=!0,a.preserveState=!0,a.replace=!0,a.viewTransition=!1,o()})}):o()}getCached(e,t={}){return En.findCached(this.getPrefetchParams(e,t))}flush(e,t={}){En.remove(this.getPrefetchParams(e,t))}flushAll(){En.removeAll()}flushByCacheTags(e){En.removeByTags(Array.isArray(e)?e:[e])}getPrefetching(e,t={}){return En.findInFlight(this.getPrefetchParams(e,t))}prefetch(e,t={},n={}){if((t.method??(nr(e)?e.method:`get`))!==`get`)throw Error(`Prefetch requests must use the GET method`);let r=this.getPendingVisit(e,{...t,async:!0,showProgress:!1,prefetch:!0,viewTransition:!1});if(r.url.origin+r.url.pathname+r.url.search===window.location.origin+window.location.pathname+window.location.search)return;let i=this.getVisitEvents(t);if(i.onBefore(r)===!1||!Yt(r))return;Ui.hide(),this.asyncRequestStream.interruptInFlight();let a={...r,...i};new Promise(e=>{let t=()=>{L.get()?e():setTimeout(t,50)};t()}).then(()=>{En.add(a,e=>{this.asyncRequestStream.send(zr.create(e,L.get()))},{cacheFor:Kt.get(`prefetch.cacheFor`),cacheTags:[],...n})})}clearHistory(){R.clear()}decryptHistory(){return R.decrypt()}resolveComponent(e,t){return L.resolve(e,t)}replace(e){this.clientVisit(e,{replace:!0})}replaceProp(e,t,n){this.replace({preserveScroll:!0,preserveState:!0,props(n){let r=typeof t==`function`?t(I(n,e),n):t;return et(F(n),e,r)},...n||{}})}appendToProp(e,t,n){this.replaceProp(e,(e,n)=>{let r=typeof t==`function`?t(e,n):t;return Array.isArray(e)||(e=e===void 0?[]:[e]),[...e,r]},n)}prependToProp(e,t,n){this.replaceProp(e,(e,n)=>{let r=typeof t==`function`?t(e,n):t;return Array.isArray(e)||(e=e===void 0?[]:[e]),[r,...e]},n)}push(e){this.clientVisit(e)}flash(e,t){let n=L.get().flash,r;if(typeof e==`function`)r=e(n);else if(typeof e==`string`)r={...n,[e]:t};else if(e&&Object.keys(e).length)r={...n,...e};else return;L.setFlash(r),Object.keys(r).length&&ln(r)}clientVisit(e,{replace:t=!1}={}){this.clientVisitQueue.add(()=>this.performClientVisit(e,{replace:t}))}performClientVisit(e,{replace:t=!1}={}){let n=L.get(),r=typeof e.props==`function`?Object.fromEntries(Object.values(n.onceProps??{}).map(e=>[e.prop,I(n.props,e.prop)])):{},i=typeof e.props==`function`?e.props(n.props,r):e.props??n.props,a=typeof e.flash==`function`?e.flash(n.flash):e.flash,{viewTransition:o,onError:s,onFinish:c,onFlash:l,onSuccess:u,...d}=e,f={...n,...d,flash:a??{},props:i},p=Mr.resolvePreserveOption(e.preserveScroll??!1,f),m=Mr.resolvePreserveOption(e.preserveState??!1,f),h=this.createVisitId();return L.set(f,{replace:t,preserveScroll:p,preserveState:m,viewTransition:o,visitId:h}).then(()=>{nn(L.get(),{replace:t,visitId:h});let n=L.get().flash;Object.keys(n).length>0&&(ln(n),l?.(n));let r=L.get().props.errors||{};if(Object.keys(r).length===0){u?.(L.get());return}let i=e.errorBag?r[e.errorBag||``]||{}:r;s?.(i)}).finally(()=>c?.(e))}performInstantSwap(e){let t=L.get(),n=Object.fromEntries((t.sharedProps??[]).filter(e=>e in t.props).map(e=>[e,t.props[e]])),r=typeof e.pageProps==`function`?e.pageProps(F(t.props),F(n)):e.pageProps,i=r===null?{...n}:{...r},a={component:e.component,url:e.url.pathname+e.url.search+e.url.hash,version:t.version,props:{...i,errors:{}},flash:{},rescuedProps:[],clearHistory:!1,encryptHistory:t.encryptHistory,sharedProps:t.sharedProps,rememberedState:{}};return L.set(a,{replace:e.replace,preserveScroll:Mr.resolvePreserveOption(e.preserveScroll,a),preserveState:!1,viewTransition:e.viewTransition,visitId:e.id})}getPrefetchParams(e,t){return{...this.getPendingVisit(e,{...t,async:!0,showProgress:!1,prefetch:!0,viewTransition:!1}),...this.getVisitEvents(t)}}createVisitId(){return pr()}getPendingVisit(e,t){if(nr(e)){let n=e;e=n.url,t.method=t.method??n.method}let n=Kt.get(`visitOptions`),r=n&&n(e.toString(),F(t))||{},i={method:`get`,data:{},replace:!1,preserveScroll:!1,preserveState:!1,only:[],except:[],headers:{},errorBag:``,forceFormData:!1,queryStringArrayFormat:`brackets`,async:!1,showProgress:!0,fresh:!1,reset:[],preserveUrl:!1,preserveErrors:!1,prefetch:!1,invalidateCacheTags:[],viewTransition:!1,component:null,pageProps:null,cached:!1,...xn(t),...xn(r)},[a,o]=Xn(e,i.data,i.method,i.forceFormData,i.queryStringArrayFormat),s={id:this.createVisitId(),cancelled:!1,completed:!1,interrupted:!1,...i,url:a,data:o};return s.prefetch&&(s.headers.Purpose=`prefetch`),s}getVisitEvents(e){return{onCancelToken:e.onCancelToken||Vr,onBefore:e.onBefore||Vr,onBeforeUpdate:e.onBeforeUpdate||Vr,onStart:e.onStart||Vr,onProgress:e.onProgress||Vr,onFinish:e.onFinish||Vr,onCancel:e.onCancel||Vr,onSuccess:e.onSuccess||Vr,onError:e.onError||Vr,onHttpException:e.onHttpException||Vr,onNetworkError:e.onNetworkError||Vr,onFlash:e.onFlash||Vr,onPrefetched:e.onPrefetched||Vr,onPrefetching:e.onPrefetching||Vr}}applyOptimisticUpdate(e,t){let n=L.get().props,r=e(F(n));if(!r)return;let i=[];for(let e of Object.keys(r))Me(n[e],r[e])||i.push(e);if(i.length===0)return;let a=L.nextOptimisticId(),o=L.get().component;for(let e of i)L.setBaseline(e,F(n[e]));L.registerOptimistic(a,e),L.setPropsQuietly({...n,...r});let s=!0,c=t.onSuccess;t.onSuccess=e=>(s=!1,c(e));let l=t.onFinish;t.onFinish=e=>{if(L.unregisterOptimistic(a),s&&L.get().component===o){let e=L.replayOptimistics();Object.keys(e).length>0&&L.setPropsQuietly({...L.get().props,...e})}return L.pendingOptimisticCount()===0&&L.clearOptimisticState(),l(e)}}loadDeferredProps(e){e&&Object.values(e).forEach(e=>{this.doReload({only:e,deferredProps:!0,preserveErrors:!0})})}},Ur=class{static createWayfinderCallback(...e){return()=>e.length===1?nr(e[0])?e[0]:e[0]():{method:typeof e[0]==`function`?e[0]():e[0],url:typeof e[1]==`function`?e[1]():e[1]}}static parseUseFormArguments(...e){return e.length===0?{rememberKey:null,data:{},precognitionEndpoint:null}:e.length===1?{rememberKey:null,data:e[0],precognitionEndpoint:null}:e.length===2?typeof e[0]==`string`?{rememberKey:e[0],data:e[1],precognitionEndpoint:null}:{rememberKey:null,data:e[1],precognitionEndpoint:this.createWayfinderCallback(e[0])}:{rememberKey:null,data:e[2],precognitionEndpoint:this.createWayfinderCallback(e[0],e[1])}}static parseSubmitArguments(e,t){return e.length===3||e.length===2&&typeof e[0]==`string`?{method:e[0],url:e[1],options:e[2]??{}}:nr(e[0])?{...e[0],options:e[1]??{}}:{...t(),options:e[0]??{}}}static mergeHeadersForValidation(e,t,n){let r=e=>(e.headers={...n??{},...e.headers??{}},e);return e&&typeof e==`object`&&!(`target`in e)?e=r(e):t&&typeof t==`object`?t=r(t):typeof e==`string`?t=r(t??{}):e=r(e??{}),[e,t]}};function Wr(e){return e.includes(`.`)?e.replace(/\\\./g,`__ESCAPED_DOT__`).split(/(\[[^\]]*\])/).filter(Boolean).map(e=>e.startsWith(`[`)&&e.endsWith(`]`)?e:e.split(`.`).reduce((e,t,n)=>n===0?t:`${e}[${t}]`)).join(``).replace(/__ESCAPED_DOT__/g,`.`):e}function Gr(e){let t=[],n=/([^\[\]]+)|\[(\d*)\]/g,r;for(;(r=n.exec(e))!==null;)r[1]===void 0?r[2]!==void 0&&t.push(r[2]===``?``:Number(r[2])):t.push(r[1]);return t}function Kr(e,t,n){let r=e;for(let e=0;e/^\d+$/.test(e)).map(Number).sort((e,t)=>e-t);return t.length===n.length&&n.length>0&&n[0]===0&&n.every((e,t)=>e===t)}function Jr(e){if(Array.isArray(e))return e.map(Jr);if(typeof e!=`object`||!e||Fn(e))return e;if(qr(e)){let t=[];for(let n=0;n/^\d+$/.test(e)).map(Number).sort((e,t)=>e-t);et(t,n,e.length>0?[...e.map(e=>i[e]),r]:[r])}else et(t,n,[r]);continue}Kr(t,e.map(String),r)}return Jr(t)}var Xr={buildDOMElement(e){let t=document.createElement(`template`);t.innerHTML=e;let n=t.content.firstChild;if(!e.startsWith(`