diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml
index 30c2b96a..679638ce 100644
--- a/fastapi_startkit/pyproject.toml
+++ b/fastapi_startkit/pyproject.toml
@@ -47,6 +47,12 @@ inertia = [
"markupsafe>=2.0",
]
+ai = [
+ "anthropic>=0.49.0",
+ "openai>=1.0.0",
+ "google-generativeai>=0.8.0",
+]
+
[dependency-groups]
dev = [
"dumpdie>=1.5.0",
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py
new file mode 100644
index 00000000..7cf60a7b
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/__init__.py
@@ -0,0 +1,31 @@
+"""FastAPI Startkit AI module.
+
+Provides a Laravel-inspired declarative API for building AI agents backed
+by Anthropic, OpenAI, or Google provider SDKs.
+"""
+
+from .agent import Agent
+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 .providers.ai_provider import AIProvider
+from .response import AgentResponse, AgentSnapshot
+
+__all__ = [
+ "Agent",
+ "AgentResponse",
+ "AgentSnapshot",
+ "AIConfig",
+ "AIProvider",
+ "AnthropicConfig",
+ "Document",
+ "GoogleConfig",
+ "OpenAIConfig",
+ "max_steps",
+ "max_tokens",
+ "memory",
+ "model",
+ "provider",
+ "timeout",
+ "top_p",
+]
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/agent.py b/fastapi_startkit/src/fastapi_startkit/ai/agent.py
new file mode 100644
index 00000000..05a7c664
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/agent.py
@@ -0,0 +1,498 @@
+"""Agent base class — subclass this and apply decorators to build an AI agent."""
+
+from __future__ import annotations
+
+import fnmatch
+from typing import Any, Callable, Iterator, Optional, Type
+
+from .document import Document
+from .response import AgentResponse, AgentSnapshot
+
+
+class Agent:
+ """
+ Base class for all agents. Subclass this and override lifecycle methods.
+
+ Class-level configuration (set via decorators or subclass attributes)::
+
+ _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)
+ """
+
+ _provider: str = "anthropic"
+ _model: str = ""
+ _max_steps: int = 10
+ _max_tokens: int = 4096
+ _timeout: float = 30.0
+ _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",
+ }
+
+ 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 []
+
+ 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,
+ *,
+ system: str | None = None,
+ model: str | None = None,
+ messages: list[dict] | 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,
+ )
+
+ match = self._match_fake(message)
+ if match is not None:
+ if isinstance(match, AgentSnapshot):
+ response = 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)
+
+ response = self._apply_middleware(message, _call)
+ self._log_call("prompt", message)
+ return self.after(response)
+
+ def stream(
+ self,
+ message: str,
+ *,
+ system: str | None = None,
+ 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)
+ fake = self._match_fake(message)
+ if fake is not None:
+ if isinstance(fake, AgentSnapshot):
+ response = fake.resolve(self, message)
+ else:
+ response = fake
+ yield response.content
+ return
+ yield from self._stream(message, system=system, 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
+
+ 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)}"
+ else:
+ 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()):
+ return value
+ return None
+
+ 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:
+ if not mw_list:
+ return fn
+ head, *tail = mw_list
+ next_fn = build(tail, fn)
+ return lambda msg: head(msg, next_fn)
+
+ 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 _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, "")
+
+ def _get_provider_options(self, override: dict | None = None) -> dict:
+ options = dict(self.provider_options().get(self._provider, {}))
+ if override:
+ provider_specific = override.get(self._provider, override)
+ if isinstance(provider_specific, dict):
+ options.update(provider_specific)
+ return options
+
+ 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)
+
+ 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())
+ 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 _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'.")
+
+ # ── Anthropic ──────────────────────────────────────────────────────────
+
+ 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,
+ )
+
+ 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,
+ )
+
+ 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 _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
+
+ ai_config = Config.get("ai")
+ return ai_config.providers[provider_name].key or None
+ except Exception:
+ return None
+
+ # ── Google ─────────────────────────────────────────────────────────────
+
+ def _run_google(
+ self,
+ system: str | None,
+ messages: list[dict],
+ model: str,
+ options: dict,
+ ) -> AgentResponse:
+ 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)
+ 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(
+ self,
+ system: str | None,
+ messages: list[dict],
+ model: str,
+ options: dict,
+ ) -> 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
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/config.py b/fastapi_startkit/src/fastapi_startkit/ai/config.py
new file mode 100644
index 00000000..af1a1acf
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/config.py
@@ -0,0 +1,48 @@
+"""AI configuration dataclasses for the FastAPI Startkit AI module."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from fastapi_startkit.environment import env
+
+
+@dataclass
+class AnthropicConfig:
+ """Configuration for the Anthropic provider."""
+
+ driver: str = "anthropic"
+ key: str = field(default_factory=lambda: env("ANTHROPIC_API_KEY", ""))
+ url: str = field(default_factory=lambda: env("ANTHROPIC_BASE_URL", "https://api.anthropic.com"))
+
+
+@dataclass
+class OpenAIConfig:
+ """Configuration for the OpenAI provider."""
+
+ driver: str = "openai"
+ 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"))
+
+
+@dataclass
+class GoogleConfig:
+ """Configuration for the Google / Gemini provider."""
+
+ driver: str = "google"
+ key: str = field(default_factory=lambda: env("GEMINI_API_KEY", "") or env("GOOGLE_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(
+ default_factory=lambda: {
+ "openai": OpenAIConfig(),
+ "anthropic": AnthropicConfig(),
+ "google": GoogleConfig(),
+ }
+ )
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/decorators.py b/fastapi_startkit/src/fastapi_startkit/ai/decorators.py
new file mode 100644
index 00000000..bd427bb6
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/decorators.py
@@ -0,0 +1,73 @@
+"""Declarative class decorators for Agent configuration."""
+
+from __future__ import annotations
+
+
+def provider(name: str):
+ """Set the LLM provider: 'anthropic', 'openai', 'google', etc."""
+
+ def decorator(cls):
+ cls._provider = name
+ return cls
+
+ return decorator
+
+
+def model(name: str = ""):
+ """Set the model identifier (e.g. 'claude-sonnet-4-6', 'gpt-4o')."""
+
+ def decorator(cls):
+ cls._model = name
+ return cls
+
+ return decorator
+
+
+def max_steps(n: int = 10):
+ """Maximum agentic loop iterations before stopping."""
+
+ def decorator(cls):
+ cls._max_steps = n
+ return cls
+
+ return decorator
+
+
+def max_tokens(n: int = 4096):
+ """Maximum output tokens per response."""
+
+ def decorator(cls):
+ cls._max_tokens = n
+ return cls
+
+ return decorator
+
+
+def timeout(seconds: float = 30.0):
+ """Request timeout in seconds."""
+
+ def decorator(cls):
+ cls._timeout = seconds
+ return cls
+
+ return decorator
+
+
+def top_p(value: float = 1.0):
+ """Top-p nucleus sampling parameter."""
+
+ def decorator(cls):
+ cls._top_p = value
+ return cls
+
+ return decorator
+
+
+def memory(backend: str = ""):
+ """Attach a named memory backend to this agent."""
+
+ def decorator(cls):
+ cls._memory_backend = backend
+ return cls
+
+ return decorator
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/document.py b/fastapi_startkit/src/fastapi_startkit/ai/document.py
new file mode 100644
index 00000000..f6dffee9
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/document.py
@@ -0,0 +1,43 @@
+"""Document helper — attach files or text to agent prompts."""
+
+from __future__ import annotations
+
+
+class Document:
+ """Attach documents to agent.prompt() calls."""
+
+ def __init__(self, content: str, name: str = "", media_type: str = "text/plain"):
+ self.content = content
+ self.name = name
+ self.media_type = media_type
+
+ @classmethod
+ def from_path(cls, path: str) -> "Document":
+ """Load a document from a local file path."""
+ with open(path) as f:
+ content = f.read()
+ return cls(content=content, name=path)
+
+ @classmethod
+ def from_storage(cls, key: str) -> "Document":
+ """Load a document from application storage (storage/)."""
+ return cls.from_path(f"storage/{key}")
+
+ def to_anthropic_block(self) -> dict:
+ """Return an Anthropic-compatible content block for this document."""
+ return {
+ "type": "document",
+ "source": {
+ "type": "text",
+ "media_type": self.media_type,
+ "data": self.content,
+ },
+ "title": self.name,
+ }
+
+ def to_openai_block(self) -> dict:
+ """Return an OpenAI-compatible content block for this document."""
+ return {
+ "type": "text",
+ "text": f"[Document: {self.name}]\n{self.content}",
+ }
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/providers/__init__.py b/fastapi_startkit/src/fastapi_startkit/ai/providers/__init__.py
new file mode 100644
index 00000000..2cdf2487
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/providers/__init__.py
@@ -0,0 +1,3 @@
+from .ai_provider import AIProvider
+
+__all__ = ["AIProvider"]
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py
new file mode 100644
index 00000000..d45f2e73
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/providers/ai_provider.py
@@ -0,0 +1,30 @@
+"""AIProvider — registers AI configuration into the service container."""
+
+from __future__ import annotations
+
+from fastapi_startkit.providers import Provider
+
+
+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.config import AIConfig
+
+ self.app.bind("ai", AIConfig())
+
+ 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)
diff --git a/fastapi_startkit/src/fastapi_startkit/ai/response.py b/fastapi_startkit/src/fastapi_startkit/ai/response.py
new file mode 100644
index 00000000..77383bdb
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/ai/response.py
@@ -0,0 +1,92 @@
+"""AgentResponse and AgentSnapshot — response containers for AI agents."""
+
+from __future__ import annotations
+
+import json
+import os
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from .agent import Agent
+
+
+@dataclass
+class AgentResponse:
+ """Returned by Agent.prompt(). Wraps the LLM response."""
+
+ content: str = ""
+ tool_calls: list[dict] = field(default_factory=list)
+ usage: dict = field(default_factory=dict)
+ raw: Any = None
+
+ def text(self) -> str:
+ """Return the text content."""
+ return self.content
+
+ def json(self) -> Any:
+ """Parse the content as JSON."""
+ return json.loads(self.content)
+
+ def __str__(self) -> str:
+ return self.content
+
+ def __bool__(self) -> bool:
+ return bool(self.content)
+
+
+@dataclass
+class AgentSnapshot:
+ """
+ Record-and-replay snapshot for testing.
+
+ - If the file at ``path`` **does not exist**: the agent calls the real API,
+ saves the response as JSON, then returns it.
+ - If the file **exists**: the saved response is loaded and returned without
+ hitting the API.
+
+ Example::
+
+ agent.fake({"*analyze*": AgentSnapshot(path="tests/fixtures/analysis.json")})
+ """
+
+ path: str
+
+ def exists(self) -> bool:
+ """Return True if the snapshot file is already recorded."""
+ return os.path.exists(self.path)
+
+ def load(self) -> AgentResponse:
+ """Load the recorded response from disk."""
+ with open(self.path) as f:
+ data = json.load(f)
+ return AgentResponse(
+ content=data.get("content", ""),
+ tool_calls=data.get("tool_calls", []),
+ usage=data.get("usage", {}),
+ )
+
+ def save(self, response: AgentResponse) -> None:
+ """Persist a real API response to disk for future replays."""
+ os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
+ with open(self.path, "w") as f:
+ json.dump(
+ {
+ "content": response.content,
+ "tool_calls": response.tool_calls,
+ "usage": response.usage,
+ },
+ f,
+ indent=2,
+ )
+
+ 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)
+ self.save(response)
+ return response
diff --git a/fastapi_startkit/src/fastapi_startkit/facades/AI.py b/fastapi_startkit/src/fastapi_startkit/facades/AI.py
new file mode 100644
index 00000000..739e468c
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/facades/AI.py
@@ -0,0 +1,5 @@
+from .Facade import Facade
+
+
+class AI(metaclass=Facade):
+ key = "ai"
diff --git a/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi b/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi
new file mode 100644
index 00000000..b6b8ab22
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/facades/AI.pyi
@@ -0,0 +1,19 @@
+from fastapi_startkit.ai.config import AIConfig
+
+class AI:
+ """Facade for accessing the AI configuration registered under the 'ai' key."""
+
+ @staticmethod
+ def config() -> AIConfig:
+ """Return the full AIConfig object."""
+ ...
+
+ @staticmethod
+ def default() -> str:
+ """Return the default AI provider name."""
+ ...
+
+ @staticmethod
+ def providers() -> dict:
+ """Return the configured provider configs (OpenAIConfig, AnthropicConfig, GoogleConfig)."""
+ ...
diff --git a/fastapi_startkit/tests/ai/__init__.py b/fastapi_startkit/tests/ai/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/fastapi_startkit/tests/ai/test_agent_decorators.py b/fastapi_startkit/tests/ai/test_agent_decorators.py
new file mode 100644
index 00000000..0ce11bc4
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_agent_decorators.py
@@ -0,0 +1,206 @@
+"""Tests for Agent class decorators."""
+
+from fastapi_startkit.ai.agent import Agent
+from fastapi_startkit.ai.decorators import (
+ max_steps,
+ max_tokens,
+ memory,
+ model,
+ provider,
+ timeout,
+ top_p,
+)
+
+
+# ─── 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
+
+
+# ─── Decorator: @timeout ──────────────────────────────────────────────────────
+
+
+def test_timeout_decorator_sets_seconds():
+ @timeout(60.0)
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._timeout == 60.0
+
+
+def test_timeout_decorator_sets_fractional():
+ @timeout(2.5)
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._timeout == 2.5
+
+
+# ─── Decorator: @top_p ────────────────────────────────────────────────────────
+
+
+def test_top_p_decorator_sets_value():
+ @top_p(0.9)
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._top_p == 0.9
+
+
+def test_top_p_decorator_sets_zero():
+ @top_p(0.0)
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._top_p == 0.0
+
+
+# ─── Decorator: @memory ───────────────────────────────────────────────────────
+
+
+def test_memory_decorator_sets_backend():
+ @memory("redis")
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._memory_backend == "redis"
+
+
+def test_memory_decorator_sets_custom_backend():
+ @memory("postgres")
+ class MyAgent(Agent):
+ pass
+
+ assert MyAgent._memory_backend == "postgres"
+
+
+# ─── Stacking multiple decorators ─────────────────────────────────────────────
+
+
+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
+
+ 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"
+
+
+def test_stacking_does_not_affect_base_class():
+ """Decorator-applied values must not leak into the Agent base class."""
+
+ @provider("openai")
+ @model("gpt-4o")
+ class SubAgent(Agent):
+ pass
+
+ # Base Agent must retain its own defaults
+ assert Agent._provider == "anthropic"
+ assert Agent._model == ""
+
+ # Subclass has decorated values
+ assert SubAgent._provider == "openai"
+ assert SubAgent._model == "gpt-4o"
+
+
+def test_instance_inherits_class_config():
+ """Instantiating a decorated class reads the right config values."""
+
+ @provider("openai")
+ @max_tokens(256)
+ class TinyAgent(Agent):
+ pass
+
+ agent = TinyAgent()
+ assert agent._provider == "openai"
+ assert agent._max_tokens == 256
diff --git a/fastapi_startkit/tests/ai/test_agent_document.py b/fastapi_startkit/tests/ai/test_agent_document.py
new file mode 100644
index 00000000..997ed0d9
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_agent_document.py
@@ -0,0 +1,151 @@
+"""Tests for the Document attachment helper."""
+
+import os
+import tempfile
+
+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"
diff --git a/fastapi_startkit/tests/ai/test_agent_fake.py b/fastapi_startkit/tests/ai/test_agent_fake.py
new file mode 100644
index 00000000..ff52563c
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_agent_fake.py
@@ -0,0 +1,276 @@
+"""Tests for Agent.fake(), assert_prompted(), assert_not_prompted(), and reset()."""
+
+import json
+import os
+import tempfile
+
+import pytest
+from fastapi_startkit.ai.agent import Agent
+from fastapi_startkit.ai.response import AgentResponse, AgentSnapshot
+
+
+# ─── Helpers ──────────────────────────────────────────────────────────────────
+
+
+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:
+ 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)
+
+
+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")
+
+ 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)
+
+ result = agent.prompt("test message")
+ assert result.content == "live result"
+
+ # 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"
+
+
+# ─── assert_prompted() ────────────────────────────────────────────────────────
+
+
+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")})
+
+ agent.prompt("first")
+ agent.prompt("second")
+ agent.assert_prompted(times=2) # must not raise
+
+
+def test_assert_prompted_times_fails_when_count_mismatch():
+ agent = SimpleAgent()
+ agent.fake({"*": AgentResponse(content="ok")})
+
+ agent.prompt("only once")
+
+ with pytest.raises(AssertionError):
+ agent.assert_prompted(times=2)
+
+
+def test_assert_prompted_fails_when_never_called():
+ agent = SimpleAgent()
+
+ with pytest.raises(AssertionError):
+ agent.assert_prompted()
+
+
+def test_assert_prompted_times_zero_passes_when_never_called():
+ agent = SimpleAgent()
+ agent.assert_prompted(times=0) # must not raise
+
+
+# ─── assert_not_prompted() ────────────────────────────────────────────────────
+
+
+def test_assert_not_prompted_passes_when_no_calls_made():
+ agent = SimpleAgent()
+ agent.assert_not_prompted() # must not raise
+
+
+def test_assert_not_prompted_fails_after_one_call():
+ agent = SimpleAgent()
+ agent.fake({"*": AgentResponse(content="ok")})
+
+ agent.prompt("a prompt")
+
+ with pytest.raises(AssertionError):
+ agent.assert_not_prompted()
+
+
+# ─── 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
+
+
+def test_fake_after_reset_works_normally():
+ agent = SimpleAgent()
+ agent.fake({"*": AgentResponse(content="first fake")})
+ agent.prompt("call")
+ agent.reset()
+
+ agent.fake({"*": AgentResponse(content="second fake")})
+ result = agent.prompt("call again")
+ assert result.content == "second fake"
+
+
+# ─── stream() respects fake() ─────────────────────────────────────────────────
+
+
+def test_stream_returns_fake_response():
+ agent = SimpleAgent()
+ agent.fake({"*hello*": AgentResponse(content="Faked stream!")})
+
+ chunks = list(agent.stream("hello world"))
+
+ assert chunks == ["Faked stream!"]
+ agent.assert_prompted(times=1)
diff --git a/fastapi_startkit/tests/ai/test_agent_response.py b/fastapi_startkit/tests/ai/test_agent_response.py
new file mode 100644
index 00000000..d57f7b5b
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_agent_response.py
@@ -0,0 +1,136 @@
+"""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"
+
+
+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
+
+
+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
diff --git a/fastapi_startkit/tests/ai/test_config.py b/fastapi_startkit/tests/ai/test_config.py
new file mode 100644
index 00000000..a5c91390
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_config.py
@@ -0,0 +1,149 @@
+"""Tests for AI configuration dataclasses."""
+
+from fastapi_startkit.ai.config 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"
+
+
+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"
+
+
+# ─── OpenAIConfig ─────────────────────────────────────────────────────────────
+
+
+def test_openai_config_driver_is_openai():
+ config = OpenAIConfig()
+ assert config.driver == "openai"
+
+
+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_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_openai_config_url_default():
+ config = OpenAIConfig()
+ assert config.url == "https://api.openai.com/v1"
+
+
+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"
+
+
+# ─── GoogleConfig ─────────────────────────────────────────────────────────────
+
+
+def test_google_config_driver_is_google():
+ config = GoogleConfig()
+ assert config.driver == "google"
+
+
+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_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_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_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 == ""
diff --git a/fastapi_startkit/tests/ai/test_provider.py b/fastapi_startkit/tests/ai/test_provider.py
new file mode 100644
index 00000000..ddf97379
--- /dev/null
+++ b/fastapi_startkit/tests/ai/test_provider.py
@@ -0,0 +1,118 @@
+"""Tests for AIProvider service provider."""
+
+from unittest.mock import MagicMock
+
+from fastapi_startkit.ai.config import AIConfig
+from fastapi_startkit.ai.providers.ai_provider import AIProvider
+from fastapi_startkit.providers import Provider
+
+
+# ─── AIProvider class contract ────────────────────────────────────────────────
+
+
+def test_ai_provider_is_a_provider():
+ assert issubclass(AIProvider, Provider)
+
+
+def test_ai_provider_key():
+ assert AIProvider.provider_key == "ai"
+
+
+# ─── AIProvider.register() ────────────────────────────────────────────────────
+
+
+def test_register_binds_ai_config_to_container():
+ """register() must call app.bind('ai', )."""
+ fake_app = MagicMock()
+ provider = AIProvider(fake_app)
+
+ provider.register()
+
+ 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)
+
+
+def test_register_does_not_raise():
+ fake_app = MagicMock()
+ provider = AIProvider(fake_app)
+
+ # Should not raise any exception
+ provider.register()
+
+
+# ─── AIProvider.boot() ────────────────────────────────────────────────────────
+
+
+def test_boot_sets_ai_in_config_store():
+ """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()
+
+ # Verify config store received the AIConfig under the 'ai' key
+ fake_config_store.set.assert_called_once_with("ai", ai_config_instance)
+
+
+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(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)
diff --git a/fastapi_startkit/uv.lock b/fastapi_startkit/uv.lock
index 50813f3e..3ded87ed 100644
--- a/fastapi_startkit/uv.lock
+++ b/fastapi_startkit/uv.lock
@@ -46,6 +46,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
+[[package]]
+name = "anthropic"
+version = "0.109.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "docstring-parser" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/54/0b/ce24a4f275573f5e436ca954faca60c759d58ed152b8fa36a1e3b888e261/anthropic-0.109.1.tar.gz", hash = "sha256:83e06b3d9d40ff5898f588020e0cc4e42187de954549a3b5fbe6e2685a09c785", size = 927569, upload-time = "2026-06-09T23:55:24.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/0f/a6110d713370bc92f074a622f8a5ebdec7e92360149b1048dca258a07b2f/anthropic-0.109.1-py3-none-any.whl", hash = "sha256:ce7d94a7657f2aa29338cca448945eac621b4f62c1794cf461cb32847223e9b8", size = 923851, upload-time = "2026-06-09T23:55:23.348Z" },
+]
+
[[package]]
name = "anyio"
version = "4.13.0"
@@ -117,6 +136,8 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
@@ -124,6 +145,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
@@ -131,18 +157,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
@@ -354,6 +393,7 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
@@ -365,6 +405,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
@@ -376,6 +419,9 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
@@ -387,6 +433,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
@@ -398,6 +455,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
[[package]]
name = "docutils"
version = "0.22.4"
@@ -527,7 +593,7 @@ wheels = [
[[package]]
name = "fastapi-startkit"
-version = "0.33.0"
+version = "0.38.0"
source = { editable = "." }
dependencies = [
{ name = "cleo" },
@@ -540,6 +606,11 @@ dependencies = [
]
[package.optional-dependencies]
+ai = [
+ { name = "anthropic" },
+ { name = "google-generativeai" },
+ { name = "openai" },
+]
database = [
{ name = "faker" },
{ name = "sqlalchemy", extra = ["asyncio"] },
@@ -586,23 +657,26 @@ dev = [
requires-dist = [
{ name = "aiomysql", marker = "extra == 'mysql'", specifier = ">=0.2.0" },
{ name = "aiosqlite", marker = "extra == 'sqlite'", specifier = ">=0.22.1" },
+ { name = "anthropic", marker = "extra == 'ai'", specifier = ">=0.49.0" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29.0" },
{ name = "cleo", specifier = ">=2.1.0,<3.0.0" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "dotty-dict", specifier = ">=1.3.1" },
{ name = "faker", marker = "extra == 'database'", specifier = ">=40.13.0" },
{ name = "fastapi", extras = ["standard"], marker = "extra == 'fastapi'", specifier = ">=0.124.4,<0.125.0" },
+ { name = "google-generativeai", marker = "extra == 'ai'", specifier = ">=0.8.0" },
{ name = "inflection", specifier = ">=0.5.1" },
{ 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 = "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" },
{ name = "pydantic", specifier = ">=2.12.5" },
{ 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"]
+provides-extras = ["fastapi", "database", "sqlite", "postgres", "mysql", "vite", "inertia", "ai"]
[package.metadata.requires-dev]
dev = [
@@ -693,6 +767,115 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/fd/5390ec4f49100f3ecb9968a392f9e6d039f1e3fe0ecd28443716ff01e589/fastar-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:76c1359314355eafbc6989f20fb1ad565a3d10200117923b9da765a17e2f6f11", size = 461049, upload-time = "2026-04-13T17:11:25.918Z" },
]
+[[package]]
+name = "google-ai-generativelanguage"
+version = "0.6.15"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core", extra = ["grpc"] },
+ { name = "google-auth" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" },
+]
+
+[[package]]
+name = "google-api-core"
+version = "2.25.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "googleapis-common-protos" },
+ { name = "proto-plus" },
+ { name = "protobuf" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/cd/63f1557235c2440fe0577acdbc32577c5c002684c58c7f4d770a92366a24/google_api_core-2.25.2.tar.gz", hash = "sha256:1c63aa6af0d0d5e37966f157a77f9396d820fba59f9e43e9415bc3dc5baff300", size = 166266, upload-time = "2025-10-03T00:07:34.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/d8/894716a5423933f5c8d2d5f04b16f052a515f78e815dab0c2c6f1fd105dc/google_api_core-2.25.2-py3-none-any.whl", hash = "sha256:e9a8f62d363dc8424a8497f4c2a47d6bcda6c16514c935629c257ab5d10210e7", size = 162489, upload-time = "2025-10-03T00:07:32.924Z" },
+]
+
+[package.optional-dependencies]
+grpc = [
+ { name = "grpcio" },
+ { name = "grpcio-status" },
+]
+
+[[package]]
+name = "google-api-python-client"
+version = "2.197.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-api-core" },
+ { name = "google-auth" },
+ { name = "google-auth-httplib2" },
+ { name = "httplib2" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/09/081d66357118bd260f8f182cb1b2dd5bd32ca88e3714d7c93896cab946fc/google_api_python_client-2.197.0.tar.gz", hash = "sha256:32e03977eda4a66eafc6ae58dc9ec46426b6025636d5ef019c5703013eddd4e5", size = 14707398, upload-time = "2026-05-28T20:23:12.498Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/e5/e9cc221fd75230974d4ef45eb72d2261feca3c110d5554215d516bfe6534/google_api_python_client-2.197.0-py3-none-any.whl", hash = "sha256:0f8b89aa75768161dd4f5092d6bcb386c13236b32e0d9a938c02f71342094d14", size = 15287302, upload-time = "2026-05-28T20:23:09.683Z" },
+]
+
+[[package]]
+name = "google-auth"
+version = "2.53.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "pyasn1-modules" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/ad/ff781329bbbdc0974a098d996e89c9e1f7024262f9e3eec442fbb9ad1ac6/google_auth-2.53.0.tar.gz", hash = "sha256:e7e6aa16f6bee7b2b264830fd04f08087a1d5a836df516251a5d15327b246c9c", size = 335844, upload-time = "2026-05-15T20:53:07.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/c9/db44165ba7c581268c6d46017ef63339110378305062830104fc7fa144cb/google_auth-2.53.0-py3-none-any.whl", hash = "sha256:6e7449917c599b35126a99ec268ec6880301f2fea41dce198fe8fd83ff642b68", size = 246071, upload-time = "2026-05-15T20:53:05.609Z" },
+]
+
+[[package]]
+name = "google-auth-httplib2"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-auth" },
+ { name = "httplib2" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/b3/f192c8bc7e41e0ebdbd95afcae4783417a34b6a6af62d22daf22c3fd38fc/google_auth_httplib2-0.4.0.tar.gz", hash = "sha256:d5b030a204b7a4b4d553ba9ca701b62481ee2b74419325580be70f7d85ffed35", size = 11161, upload-time = "2026-05-07T08:03:46.878Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/be/954c35a62b9e31de66b0a43c225c9b6bb9e0f98d6b1dc110a2308e3644f5/google_auth_httplib2-0.4.0-py3-none-any.whl", hash = "sha256:8e55cfafa3358cba85f6cad4a886138e88e158d71e7e5c9ee5936a5c1507fb91", size = 9529, upload-time = "2026-05-07T08:02:12.375Z" },
+]
+
+[[package]]
+name = "google-generativeai"
+version = "0.8.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "google-ai-generativelanguage" },
+ { name = "google-api-core" },
+ { name = "google-api-python-client" },
+ { name = "google-auth" },
+ { name = "protobuf" },
+ { name = "pydantic" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/0f/ef33b5bb71437966590c6297104c81051feae95d54b11ece08533ef937d3/google_generativeai-0.8.6-py3-none-any.whl", hash = "sha256:37a0eaaa95e5bbf888828e20a4a1b2c196cc9527d194706e58a68ff388aeb0fa", size = 155098, upload-time = "2025-12-16T17:53:58.61Z" },
+]
+
+[[package]]
+name = "googleapis-common-protos"
+version = "1.75.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
+]
+
[[package]]
name = "greenlet"
version = "3.4.0"
@@ -740,6 +923,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
]
+[[package]]
+name = "grpcio"
+version = "1.81.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/15/f3/23f47b24f8d8c2028eba501db3acfbb2f592cbb5995eaa6e363a627b74d7/grpcio-1.81.0.tar.gz", hash = "sha256:a5acd7efd3b1fe9b4eb0bcaaa1507eed68a0ad0678b654c3f7b464df9ba9dca5", size = 13032272, upload-time = "2026-06-01T05:56:22.827Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/d5/896a3aaf07068d707d88b282a04914b872db4d32d3c7e6d88e43a3b911fa/grpcio-1.81.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:57b3b0e73a518fa286959b40c3eddd02703504ca186e8b7b2945954519bd8b2c", size = 6053538, upload-time = "2026-06-01T05:54:58.965Z" },
+ { url = "https://files.pythonhosted.org/packages/68/6a/7e3eafa4727cd405ff917605ed2949e2af162f233f5cbdd773723a5fea7d/grpcio-1.81.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8bb1789c94322a13336a2b6c58d9c14d68f8628b6e24205a799c69f5bf8516ce", size = 12053447, upload-time = "2026-06-01T05:55:01.862Z" },
+ { url = "https://files.pythonhosted.org/packages/16/79/a4302aa82428de48a922421f522b027a1a727ab4d0926368454aa953d36d/grpcio-1.81.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e4d053900a0d24b75d7521139a3872150301b3d6bde3bed5e12318fb25791e4d", size = 6595872, upload-time = "2026-06-01T05:55:04.946Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/1f/7ff2850eaefbecf99af3f624dbb28dd1ad6c5fd4c1d8c26909ed6482673b/grpcio-1.81.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:db217c2e52931719f9937bd12082cd4d7b495b35803d5760686975c285924bf8", size = 7303857, upload-time = "2026-06-01T05:55:07.205Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/98/1f3896a9baae1f2aedf4e99c55291d6fa1f30ad9603d63bc18bda967b53e/grpcio-1.81.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:19f201da7b4e5c0559198abe5a97157e726f3abe6e8f5e832d4a50740f6dcc22", size = 6809676, upload-time = "2026-06-01T05:55:09.513Z" },
+ { url = "https://files.pythonhosted.org/packages/34/8b/3441983718095208c5d797fd3239882e97ea89a629f41c8df94b4eef4df9/grpcio-1.81.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:275144b0115353339dbb8a6f28a9cf8997b5bf40e37f8f66ac0b0ea57e95b43f", size = 7412654, upload-time = "2026-06-01T05:55:12.777Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/98/1eddf07df6e4fe85cf67502a793f7b05468b2dca3d1ef35b972cf5d54468/grpcio-1.81.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5192857589f223e5a98ff0e31f6e551b19040e647d17bfe10116c8a2ce3b8696", size = 8408026, upload-time = "2026-06-01T05:55:15.514Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/73/3860341e6a1f5347be6ab35c6c0e1e3a8eb59d010388207fd561dcf01a88/grpcio-1.81.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6ff087cb1f563f47b504b4e29e684129fc5ae4863faf3ebca08a327764ee6cb", size = 7849498, upload-time = "2026-06-01T05:55:18.078Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3f/0ea06bd85c701966aa3f8f37314f2ed83520d2b7590f42d643d445d8bc8b/grpcio-1.81.0-cp312-cp312-win32.whl", hash = "sha256:98c6240f563178fc5877bd50e6ff274463e53e1472128f4110742450739659fa", size = 4184161, upload-time = "2026-06-01T05:55:20.127Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e3/a7c387406827a86f99ad7838b995bf9b4a182ffe2d2c439ed2873efec952/grpcio-1.81.0-cp312-cp312-win_amd64.whl", hash = "sha256:87e33b7afcfb3585121b5f007d2c52b8c534104d18f556e840d35193ca2a9141", size = 4929958, upload-time = "2026-06-01T05:55:22.736Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/29/779ee53c931d0fd55c1d459fde43e485172caa3ac87cbd43d003a13a0185/grpcio-1.81.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:62bbe463c9f0f2ff24e31bd25f8dd8b4bae78900e315915a3195a0ef1471a855", size = 6054973, upload-time = "2026-06-01T05:55:25.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/b6/7211807926b5a17f8d9a5d47c739a163d6812fefe3e4714e81cf92945ed7/grpcio-1.81.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43c121e135ae44d1559b430db2b2dfad7421cbbe40e1deba506c7dc62b439719", size = 12048662, upload-time = "2026-06-01T05:55:28.453Z" },
+ { url = "https://files.pythonhosted.org/packages/64/89/b1b93ef6b34bd20bbaf707fa99133bc9cc302139d5ec6f77a165c7169796/grpcio-1.81.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f345de40ef2e65f63645d53d251824e6070e07804827c5b00ec2e44555f9f901", size = 6599116, upload-time = "2026-06-01T05:55:31.185Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/bc/c89f9b9d1c22895715356a1e009554dae66319e97826bb4d30bcda7d29e8/grpcio-1.81.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8c0855a350886f713b9e458e2a10d208009dcaa849f574e39cd6067db1fe1279", size = 7307591, upload-time = "2026-06-01T05:55:33.463Z" },
+ { url = "https://files.pythonhosted.org/packages/65/4a/1df2a4cb4a1386e066ab7e4175e34bb884b35ccb60d3621c09c84af6aabb/grpcio-1.81.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a524cd530900bd24511fcb7f2ed144da4ea37711c4b094475d0bceca7a93a170", size = 6811797, upload-time = "2026-06-01T05:55:36.731Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/dc/fa189d20601a1be25b08850cfb733879bbb1047b62a8feec3a60e3e1a87b/grpcio-1.81.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e7746ba3e6efc9e2b748eff59470a2b8684d5a9ec607c6580bcaa5be175820bc", size = 7415131, upload-time = "2026-06-01T05:55:39.451Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/5625c48cb48d23c6631b3e5294f88e4c751f22a52591ae78859fab96dca1/grpcio-1.81.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:aaaa4f7f2057d795952e4eacf3f342be8b5b156992f6ac85023c8b98794ebd47", size = 8408398, upload-time = "2026-06-01T05:55:42.219Z" },
+ { url = "https://files.pythonhosted.org/packages/75/34/0f8202c6809a46c2b4d69125ef3667c40b1c211f8e19930e5fa1f1197039/grpcio-1.81.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fba53cb96004b2b7fb758b46b2288cb49d0b658316a4e73f3ef67230616ee65", size = 7844481, upload-time = "2026-06-01T05:55:44.849Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/95/c3366b5b5edf4c4adc90f2e29ca16e57965a8e56dc8d2ee89565ba1905bb/grpcio-1.81.0-cp313-cp313-win32.whl", hash = "sha256:c197e2ef75a442528072b29e9755da299110e8610e8bcbb59a6b4cf55384f005", size = 4182777, upload-time = "2026-06-01T05:55:47.459Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/a7/932f2f748511a32e641a2aba0d30dded3ed6e8bc330e0924e4d5d86853e6/grpcio-1.81.0-cp313-cp313-win_amd64.whl", hash = "sha256:194eddfacc84d80f50512e9fd4ee851d5f2499f18f299c95aa8fb4748f0537e0", size = 4928085, upload-time = "2026-06-01T05:55:50.158Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/1d/28b231333857deb840bc3d182ae087510170ea6d68f21393aeb0fe499530/grpcio-1.81.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:a9351055f52660b58f3d4890ea66188b5134399f82b11aa0c55bd4b99eff5390", size = 6055712, upload-time = "2026-06-01T05:55:52.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/b8/999c14f9dff0fc47549d2e827cba1343ddc18e1d1bf0d06d2cf628eecbd9/grpcio-1.81.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:300f3337b6425fd16ead9a4f9b2ac25801acb64aa5bc0b99eb69901645b2b1d2", size = 12057189, upload-time = "2026-06-01T05:55:55.952Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/3d/1fbde079572562af65351151d840525a13879eb7b481d35b55cd64c6127a/grpcio-1.81.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:97bbd623f7ded558fd4f7cb5a4f600c4d4de65c5dd364c83a5b14b2a10a2d3b5", size = 6608136, upload-time = "2026-06-01T05:55:59.069Z" },
+ { url = "https://files.pythonhosted.org/packages/32/89/1f17cb6882abfd8e5a303a25d5d1665abef5a8c499a96198c65a651d1b85/grpcio-1.81.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ff83d889e3ebf6341c8c7864ad8031591ad5ca61599072fc511644d1eb962d2b", size = 7307045, upload-time = "2026-06-01T05:56:02.376Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/f98e91b2e755652e637ea2144318b0229b290062199f761b445fe1fa6015/grpcio-1.81.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c4fe218c5a35e1d87a5a26544237f1fa41dfd9cbd3c856b0810a30061f8b0aaf", size = 6812794, upload-time = "2026-06-01T05:56:05.777Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/0c/77892d715ac41e7ec0ace2a50080ffb64e189188056f607a66fe0014d1ee/grpcio-1.81.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b8b025b6af43ee0ad4a70307025d77bcab5adde7c4597786010d802c203e9fc5", size = 7422767, upload-time = "2026-06-01T05:56:08.524Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/b8/aa04590c6564714d94954515f15a236e59d4b9b3ad01e615f1b706d7792d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:3d4e0ce5a40a998cf608c8ba60ecfe18fdf364a9aa193ae4ac3faeecd0e86757", size = 8408551, upload-time = "2026-06-01T05:56:11.283Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3d/4f4a3450a1973568910c6909cb74abbf2126f68aefae5976962f9f7ad50d/grpcio-1.81.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa948712c8e5fa40ec250870bda14bc7578e1bb832a8912d9d2a0f720518edbe", size = 7846468, upload-time = "2026-06-01T05:56:14.536Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f4/5827fd248221ad3b44161c23ce9b5f4ee405b04fc6da5fd402a9aa87a84a/grpcio-1.81.0-cp314-cp314-win32.whl", hash = "sha256:fbbe81314a9d92156abce8b62c09364eb8bafc0ca2a19919a45ec64b5c6cb664", size = 4264427, upload-time = "2026-06-01T05:56:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/127dc2b246096ad50ef7c8d9b7b31d757787aeb796368bcdd4454e4204c4/grpcio-1.81.0-cp314-cp314-win_amd64.whl", hash = "sha256:b93cee313cae4e113fbb3a0ce1ea5633db6f63cfde2b2dc1d817429026b2a50b", size = 5070848, upload-time = "2026-06-01T05:56:19.735Z" },
+]
+
+[[package]]
+name = "grpcio-status"
+version = "1.71.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "googleapis-common-protos" },
+ { name = "grpcio" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" },
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -762,6 +1000,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
+[[package]]
+name = "httplib2"
+version = "0.31.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" },
+]
+
[[package]]
name = "httptools"
version = "0.7.1"
@@ -908,6 +1158,78 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
+[[package]]
+name = "jiter"
+version = "0.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/b5/55f06bb281d92fb3cc86d14e1def2bd908bb77693183e7cb1f5a3c388b0c/jiter-0.15.0.tar.gz", hash = "sha256:4251acc80e2b7c9b7b8823456ea0fceeb0734dac2df7636d3c711b38476b5a76", size = 166640, upload-time = "2026-05-19T10:09:48.361Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/53/4f6bddbcde3c71e56d0aa1337ec95950f3d27dd4153e25aadf0feac71751/jiter-0.15.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0e90a1c315a0226ec822d973817967f9223b7701546c8c2a7913e7ab0926294d", size = 308793, upload-time = "2026-05-19T10:07:35.25Z" },
+ { url = "https://files.pythonhosted.org/packages/01/84/c01099b59a285a1ebba64ae93f62bfa036675340fd1b0045ae65890a0442/jiter-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8c9004af7c8d67cce7f1aae1026fb55607f4aa600710d08ede3a3ce4aeefe7e0", size = 309570, upload-time = "2026-05-19T10:07:36.919Z" },
+ { url = "https://files.pythonhosted.org/packages/58/64/8fb7f9d45bb98190355454cd04dad8d8f27223d6bd52f83af07f637168a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c210f8b35dc6f30aafd4b4365ca89b9d1189f21ab49b8e68fa6322a847aef138", size = 336783, upload-time = "2026-05-19T10:07:38.694Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b6/f5739011d009b3a30f6a53c5240979030ba29ae46a8c67e3a15759f7c37d/jiter-0.15.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f30bae8bc1c2d613e28e5af3e8cceb09b742f1c8a8a5f839fb67afaffc03b61", size = 363555, upload-time = "2026-05-19T10:07:40.832Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/12/98a9d9f766665e8a3b6252454e17cb0c464606a28cf2fa09399b003345fa/jiter-0.15.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e71b6d10cfc284c9bf36bd885e8d44c46f688ce50aa91b5edd90181dea687", size = 452255, upload-time = "2026-05-19T10:07:42.62Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/d5/60f972840f79c5e7544fce567c56f1e4e50468f996baba3e78d823dd62a6/jiter-0.15.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ab068bce62a45aa3e7367eceaffb5dde60b7eb853be8dece45132e3d0ff4879", size = 373559, upload-time = "2026-05-19T10:07:44.201Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/cf/d46ef1234ba335aabc2f013210db8e0821a22f5e644a2e9449df199ecc23/jiter-0.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa248c9eb220197d363f688818dac2fd4b2f0cd7d843ca7105d652034823427d", size = 346055, upload-time = "2026-05-19T10:07:46.005Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/63/4d2749d8d54d230bad9b3a6b0d00cc28c6ff6b2fdffc26a8ccf76cc5a974/jiter-0.15.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2a77aadd57cac1682e4401a72724d2796d89a4ba129b1a5812aa94ee480826eb", size = 351406, upload-time = "2026-05-19T10:07:47.855Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b9/9965b990035d8773328e0a8c8b457a87bf2b19f6c4126d9d99296be5d16a/jiter-0.15.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2ae901f3a55bfafdde31d289590fa25e3245735a2b1e8c7cc15871710a002871", size = 389357, upload-time = "2026-05-19T10:07:49.665Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/55/9ddf903deda1413e87fed792f416b7123daee5b8efbad6a202a7421c36a5/jiter-0.15.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f0b271b462769543716f92d3a4f90527df6ef5ed05ee95ec4137f513e21e1b77", size = 517263, upload-time = "2026-05-19T10:07:51.537Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/76/a0c40ad064d3a20a4fde231e35d56e9a01ce82164278180e82d5daf85469/jiter-0.15.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2fb6a5d26af81fc0f00f9360a891e05cf755e149bba391c4d563adc54812973d", size = 548646, upload-time = "2026-05-19T10:07:53.196Z" },
+ { url = "https://files.pythonhosted.org/packages/23/4f/eca9b954942916ba2f453891b8593ab444cd872396fe66a3936616f236f3/jiter-0.15.0-cp312-cp312-win32.whl", hash = "sha256:c2f6bb8b5216ab9e7873bc08b5d7bef2b8abbb578a3069bf1cd14a45d71d771d", size = 206427, upload-time = "2026-05-19T10:07:55.307Z" },
+ { url = "https://files.pythonhosted.org/packages/95/bf/8ead82a87495149542748e828d153fd232a512a22c83b02c4815c1a9c7d8/jiter-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:40b2c7e92c44a84d748d21706c68dc6ff8161d80b59c99d774721a0d2317d7c7", size = 197300, upload-time = "2026-05-19T10:07:56.651Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/e4/9b8a78fb2d894471bc344e37f1949bdd784bd914d031dba0ba3a40c71dd7/jiter-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:cc0bc345cf2df9d1c00ac443f50d543c1ccfa8b0422cb85b1ab70d681c0b255b", size = 192702, upload-time = "2026-05-19T10:07:58.307Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f4/f708c900ecee41b2025ef8413d5351e5649eb2125c506f6720cc69b06f5c/jiter-0.15.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1c11465f97e2abf45a014b83b730222f8f1c5335e802c7055a67d50de6f1f4e3", size = 307829, upload-time = "2026-05-19T10:07:59.704Z" },
+ { url = "https://files.pythonhosted.org/packages/86/59/db537c0949e83668c38481d426b9f2fd5ab758c4ee53a811dd0a510626a0/jiter-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e7b1776f0797956c509e123d0952d10d293a9492dea9f288ab9570ec01d1a5", size = 308445, upload-time = "2026-05-19T10:08:01.184Z" },
+ { url = "https://files.pythonhosted.org/packages/37/38/ea0e13b18c30ef951da0d47d39e7fa9edb82a93a62990ffbd7cea9b622d4/jiter-0.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:351a341c2105aa430b7047e30f1bf7975f6313b00165d3fc07be2edaf741f279", size = 336181, upload-time = "2026-05-19T10:08:02.688Z" },
+ { url = "https://files.pythonhosted.org/packages/58/fc/2303901b16c4ba05865588990a420c0b4156270b44379c20931544a1d962/jiter-0.15.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ab395feec8d249ec4044e228e98a7033f043426a265df439dc3698823f0a4e4", size = 362985, upload-time = "2026-05-19T10:08:04.394Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/6f/11bace093c52e7d4d26c8e606ccd7ae8c972189622469ec0d9e28161e28b/jiter-0.15.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2a438005b6f22d0273413484d6094d7c2c5d10ec1b3a3bf128e0d1d3ba53258", size = 453292, upload-time = "2026-05-19T10:08:05.967Z" },
+ { url = "https://files.pythonhosted.org/packages/22/db/987f2f086ca4d7a6582eb4ccd513f9b26b42d9e4243a087609a3137a8fc7/jiter-0.15.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f18f85e4218d1b40f000f42a92239a7a61a902cd42c65e6c360dbd17dcb20894", size = 373501, upload-time = "2026-05-19T10:08:07.857Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/7c/89fbcabb2739b7a5b8dc959a1b6c5761f6484f5fed3486854b3c789bb1de/jiter-0.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1aa62e277fc1cbd80e6deacae6f4d983b41b3d7728e0645c5d741a6149bba45", size = 344683, upload-time = "2026-05-19T10:08:09.431Z" },
+ { url = "https://files.pythonhosted.org/packages/30/6f/6cca7692e7dddfec6d8d76c54dc97f2af2a41df4ac0674b999df1f09a5f3/jiter-0.15.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:6550fa135c7deb8ead6af49ed7ff648532ea8334a1447fe34a36315ef79c5c29", size = 350892, upload-time = "2026-05-19T10:08:11.352Z" },
+ { url = "https://files.pythonhosted.org/packages/39/14/0338d6190cb8e6d22e677ab1d4eabd4117f67cca70c54cd04b82ff64e068/jiter-0.15.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:066f8f33f18b2419cd8213b2436fa7fbc9c499f315971cfa3ce1f9820c001b1b", size = 388723, upload-time = "2026-05-19T10:08:12.912Z" },
+ { url = "https://files.pythonhosted.org/packages/90/31/cc19f4a1bdb6afb09ce6a2f2615aa8d44d994eba0d8e6105ed1af920e736/jiter-0.15.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:75e8a04e91432dde9f1838373cf93d23726c79d3e908d319acf0e796f85592e7", size = 516648, upload-time = "2026-05-19T10:08:14.808Z" },
+ { url = "https://files.pythonhosted.org/packages/49/9f/833c541512cd091b63c10c0381973dfe11bc7a503a818c16384417e0c81e/jiter-0.15.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a97261f1fccb8e50ecd2890a96e46efdc3f57c80a197324c6777827231eca712", size = 547382, upload-time = "2026-05-19T10:08:16.927Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/11/e7b70e91f90bc4477e8eee9e8a5f7cf3cb41b4525d6394dc98a714eb8f7f/jiter-0.15.0-cp313-cp313-win32.whl", hash = "sha256:c77496cb10bd7549690fbbab3e5ec05857b83e49276f4a9423a766ddd2afcd4c", size = 205845, upload-time = "2026-05-19T10:08:18.401Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/23/5c20d9ad6f02c493e4023e5d2d09e1c1f15fe2753c9102c544aff068a88e/jiter-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b15741f501469009ae0ae90b7147958a664a7dede40aa7ff174a8a4645f546d0", size = 196842, upload-time = "2026-05-19T10:08:20.131Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/11/1eb400ef248e8c925fd883fbe325daf5e42cd1b0d308539dd332bd4f7ffc/jiter-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d6a60072b44c3c2b797a7ddcbcbbf2b34ea3cfd4721580fbfd2a09d9d9b84ba", size = 192212, upload-time = "2026-05-19T10:08:21.807Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/60/2fd8d7c79da8acf9b7b277c7616847773779356b92acfc9bb158452174da/jiter-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ef1fd24d9413f6209e00d3d5a453e67acfe004a25cc6c8e8484faed4311ab9e8", size = 315065, upload-time = "2026-05-19T10:08:23.218Z" },
+ { url = "https://files.pythonhosted.org/packages/46/f4/008fb7d65e8ac2abf00811651a661e025c4ba80bbc6f378450384ddd3aed/jiter-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:144f8e72cb53dab146347b91cceac01f5481237f2b93b4a339a1ee8f8878b67c", size = 339444, upload-time = "2026-05-19T10:08:24.701Z" },
+ { url = "https://files.pythonhosted.org/packages/00/55/90b0c7b9c6896c0f2a591dd36d36b71d22e09674bfef178fa03ba3f81499/jiter-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553fcac2ef2cb990877f9fc0833b8b629a3e6a5670b6b5fd58219b41a653ddc4", size = 347779, upload-time = "2026-05-19T10:08:26.408Z" },
+ { url = "https://files.pythonhosted.org/packages/51/6b/69666cec5000fd57734c118437394516c749ae8dbeea9fb66d6fef9c4775/jiter-0.15.0-cp313-cp313t-win_amd64.whl", hash = "sha256:774f93f65031856bf14ad9f59bdcab8b8cad501e5ceabd51ba3525f76937a25b", size = 200395, upload-time = "2026-05-19T10:08:28.055Z" },
+ { url = "https://files.pythonhosted.org/packages/39/04/a6aa62cd27e8149b0d28df5561f10f6cceaf7935a9ccf3f1c5a05f9a0cd8/jiter-0.15.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f1e1754960f38ec40613a07e5e372df67acb3b890fb383b6fb3de3e49ddbf3c7", size = 190516, upload-time = "2026-05-19T10:08:29.35Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/079f350ebf7859d081de30aa890f9e3be68516f754f3ba32366ffff4dcee/jiter-0.15.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ac0d9ddea4350974be7a221fc25895f251a8fee748c889bdced2141c0fec1a49", size = 308884, upload-time = "2026-05-19T10:08:31.667Z" },
+ { url = "https://files.pythonhosted.org/packages/04/4e/a2c30a7f69b48c03b20935d647479106fe932f6e63f75faf53937197e05d/jiter-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01a8222cf05ab1128e239421156c207949808acaaea2bdfd33130ae666786e86", size = 310028, upload-time = "2026-05-19T10:08:33.304Z" },
+ { url = "https://files.pythonhosted.org/packages/40/90/2e7cdfd3cf8ca967be38c48f5cf474d79f089efaf559a40f15984a77ae69/jiter-0.15.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:182226cbc930c9fab81bc2e41a4da672f89539906dadb05e75670ac07b94f71f", size = 337485, upload-time = "2026-05-19T10:08:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/11/15a1aa28b120b8ee5b4f1fb894c125046225f09847738bd64233d3b84883/jiter-0.15.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71683c38c825452999b5717fcae07ea708e8c93003e808be4319c1b02e3d176e", size = 364223, upload-time = "2026-05-19T10:08:36.694Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/25/f442e8af5f3d0dcf47b39e83a0efd9ee45ea946aa6d04625dc3181eae3b6/jiter-0.15.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:30f2218e6a9e5c18bc10fe6d41ac189c442c88eacf11bad9f28ef95a9bef00e6", size = 456387, upload-time = "2026-05-19T10:08:38.143Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f4/37f2d2c9f64f49af7da652ed7532bb5a2372e588e6927c3fdd76f911db65/jiter-0.15.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5157de9f76eb4bc5ea74a1219366a25f945ad305641d74e04f59c54087091aa9", size = 374461, upload-time = "2026-05-19T10:08:39.869Z" },
+ { url = "https://files.pythonhosted.org/packages/60/28/edcfbbbf0cb15436f36664a8908a0df47ab9006298d4cd937dc08ea932d6/jiter-0.15.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c5db5527c221249a876160663ab891ace358c17f7b9c93ec1478b7f0550e5c", size = 345924, upload-time = "2026-05-19T10:08:41.668Z" },
+ { url = "https://files.pythonhosted.org/packages/47/13/89fba6398dab7f202b7278c4b4aac122399d2c0183971c4a57a3b7088df5/jiter-0.15.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:3e4540b8e74e4268811ac05db226a6a128ff572e7e0ce3f1163b693cadb184cd", size = 352283, upload-time = "2026-05-19T10:08:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/da/0f6af8cef2c565a1ab44d970f268c43ccaa72707386ea6388e6fe2b6cd26/jiter-0.15.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:62ebd14e47e9aed9df4472afcb2663668ce4d74891cd54f86bf6e44029d6dc89", size = 389985, upload-time = "2026-05-19T10:08:44.915Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ec/b9cb7d6d29e24ee14910266157d2a279d7a8f60ee0df7fa840882976ba64/jiter-0.15.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0be6f5ad41a809f303f416d17cec92a7a725902fb9b4f3de3d19362ac0ef8554", size = 517695, upload-time = "2026-05-19T10:08:46.486Z" },
+ { url = "https://files.pythonhosted.org/packages/64/5e/6d1bda880723aae0ad86b4b763f044362448efe31e3e819635d41cb03451/jiter-0.15.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:813dfbb17d65328bf86e5f0905dd277ba2265d3ca20556e86c0c7035b7182e5a", size = 548868, upload-time = "2026-05-19T10:08:48.026Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/72/7de501cf38dcacaf35098796f3a50e0f2e338baba18a58946c618544b809/jiter-0.15.0-cp314-cp314-win32.whl", hash = "sha256:50e51156192722a9c58db112837d3f8ef96fb3c5ecc14e95f409134b08b158ec", size = 206380, upload-time = "2026-05-19T10:08:49.738Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/a9/e19addf4b0c1bdce52c6da12351e6bc42c340c45e7c09e2158e46d293ccc/jiter-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:30ce1a5d16b5641dc935d50ef775af6a0871e3d14ab05d6fc54dff371b78e558", size = 197687, upload-time = "2026-05-19T10:08:51.088Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/c9/776b1db01db25fc6c1d58d1979a37b0a9fe787e5f5b1d062d2eaacb77923/jiter-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:510c8b3c17a0ed9ac69850c0438dada3c9b82d9c4d589fcb62002a5a9cf3a866", size = 192571, upload-time = "2026-05-19T10:08:52.451Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/f6/45bb4670bacf300fd2c7abadbfb3af376e5f1b6ae75fd9bc069891d15870/jiter-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7553333dd0930c104a5a0db8df72bf7219fe663d731383b576bb6ed6351c984d", size = 317151, upload-time = "2026-05-19T10:08:53.867Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/68/ed635ad5acd7b73e454283083bbb7c8205ad10e88b0d9d7d793b09fe8226/jiter-0.15.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2143ab06181d2b029eedcb6af3cebe95f11bbac62441781860f98ee9330a6a6", size = 341243, upload-time = "2026-05-19T10:08:55.383Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/db/3ff4176b817b8ea33879e71e13d8bc2b0d481a7ed3fe9e080f333d415c16/jiter-0.15.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eac374c5c975709b69c10f09afd199df74150172156ad10c8d4fd785b7da995", size = 363629, upload-time = "2026-05-19T10:08:56.928Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/24/5f8270e0ba9c883582f96f722f8a0b58015c7ce1f8c6d4571cf394e99b6b/jiter-0.15.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3b3b775e33d3bfaec9899edc526ae97b0da0bf9d071a46124ba419149a414f8", size = 456198, upload-time = "2026-05-19T10:08:58.618Z" },
+ { url = "https://files.pythonhosted.org/packages/45/5b/76fc02b0b5c54c3d18c60653156e2f76fde1816f9b4722db68d6ee2c897e/jiter-0.15.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3071db3346334beae1360b46da4606da57bf3528c167b3c38533afaf9f2c5", size = 373710, upload-time = "2026-05-19T10:09:00.151Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/52/4310821b0ea9277994d3e1f49fc6a4b34e4800caebacb2c0af81da59a454/jiter-0.15.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6694a173ecabc12eb60efbc0b474464ead1951ff65cd8b1e72100715c64512b", size = 349901, upload-time = "2026-05-19T10:09:01.621Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fe/67648c35b3594fba8854ac64cc8a826d8bcd18324bbdb53d77697c60b6ef/jiter-0.15.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a254e10b593624d230c365b6d616b22ca0ad65e63a16e6631c2b3466022e6ba8", size = 352438, upload-time = "2026-05-19T10:09:03.216Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/28/0a1879d07ad6b3e025a2750027363452ced93c2d16d1c9d4b153ffd51c91/jiter-0.15.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d8d2955167274e15d79a7a020afdd9b39c990eb80b2d89fca695d92dcfdd38ec", size = 388152, upload-time = "2026-05-19T10:09:04.741Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/78/46c6f6b56ba85c90021f4afd72ed42f691f8f84daacb5fe27277070e3858/jiter-0.15.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:acf4ee4d1fc55917239fe72972fb292dd773055d05eb040d36f4326e02cc2c0e", size = 517707, upload-time = "2026-05-19T10:09:06.231Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/cb/720662d4c88fcad606e826fef5424365527ba43ce4868a479aed8f8c507e/jiter-0.15.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e7196e56f1cd69af1dbb07dff02dcfb260a50b45a82d409d92a06fedb32473b5", size = 548241, upload-time = "2026-05-19T10:09:08.093Z" },
+ { url = "https://files.pythonhosted.org/packages/60/e3/935b8034fd143f21125c87d51404a9e0e1449186a494405721ff5d1d695e/jiter-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:7f6163c0f10b055245f814dcc59f4818da60dfe72f3e72ab89fc24b6bd5e9c52", size = 207950, upload-time = "2026-05-19T10:09:09.616Z" },
+ { url = "https://files.pythonhosted.org/packages/93/59/984fd9ece895953dad3e0880a650e766f5a2da2c5514f0eafdaaabbeb5f9/jiter-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c256edb05b78a111b99c4de3b1d32e31634b867fd1fc2cf726e7b7bba9854", size = 200055, upload-time = "2026-05-19T10:09:11.367Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/a4/cf8d779feb133a27a2e3bc833bccb9e13aa332cdf820497ebf72c10ce8c3/jiter-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:66b1880df2d01e206e8339769d1c7c1753bcb653efd6289e203f6f24ebada0c0", size = 191244, upload-time = "2026-05-19T10:09:12.74Z" },
+ { url = "https://files.pythonhosted.org/packages/73/38/505941b2b092fd5bbbd60a52a880db1173f1690ae6751bed3af1c9ddcb4e/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:631f13a3d04e97d4e083993b10f4b99530e3a10d953e2eb5e196b7dc7f812ce0", size = 303769, upload-time = "2026-05-19T10:09:42.203Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/a06692b29e77473f286e1ec1f426d3ca44d7b5843be8ad21d7a5f3fcdcc0/jiter-0.15.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:b6c0ffae686c39bf3737be60793783267628783ea42545632c10b291105aee45", size = 305128, upload-time = "2026-05-19T10:09:43.657Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/7270d7ad41d6061a25b950c6bf91d638bd9aacb113200a8c8d57a055fd67/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d54fb5b31dea401a41af3f8a7d2512e9b6a6a005491e6166c7e4ffab9639a9c", size = 340459, upload-time = "2026-05-19T10:09:45.452Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/8d/302cb2057b7513327b4d575cff6b1d066ee6431a5357fc3f8867cd684406/jiter-0.15.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d5d6090cdc1b7c9e780dfb04949a990adb1e301a2fc0bbcee7de4638d33f9a", size = 344469, upload-time = "2026-05-19T10:09:46.864Z" },
+]
+
[[package]]
name = "keyring"
version = "25.7.0"
@@ -1052,6 +1374,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/44/0c/37695d6b0168f6714b5c492331636a9e6123d6ec22d25876c68d06eab1b8/nh3-0.3.4-cp38-abi3-win_arm64.whl", hash = "sha256:43ad4eedee7e049b9069bc015b7b095d320ed6d167ecec111f877de1540656e9", size = 616649, upload-time = "2026-03-25T10:57:29.623Z" },
]
+[[package]]
+name = "openai"
+version = "2.41.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "distro" },
+ { name = "httpx" },
+ { name = "jiter" },
+ { name = "pydantic" },
+ { name = "sniffio" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" }
+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 = "packaging"
version = "26.1"
@@ -1113,6 +1454,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
+[[package]]
+name = "proto-plus"
+version = "1.28.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/56/e647b0c675392d2da368da7b6f158f7368b18542fd6f7d7400a2f39de000/proto_plus-1.28.0.tar.gz", hash = "sha256:38e5696342835b08fc116f30a25665b29531cda9d5d5643e9b81fc312385abd9", size = 57221, upload-time = "2026-05-07T08:04:50.811Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/20/b122d4626976acb81132036d2ad1bb35a1a8775fceb837ec30964622516a/proto_plus-1.28.0-py3-none-any.whl", hash = "sha256:a630604310899e73c59ec302e5765c058d412b2f090b9c79c8822589f14955b8", size = 50410, upload-time = "2026-05-07T08:03:31.962Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "5.29.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
+ { url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
+]
+
+[[package]]
+name = "pyasn1"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
+]
+
+[[package]]
+name = "pyasn1-modules"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyasn1" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
+]
+
[[package]]
name = "pycparser"
version = "3.0"
@@ -1235,6 +1623,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/4c/ad33b92b9864cbde84f259d5df035a6447f91891f5be77788e2a3892bce3/pymysql-1.1.2-py3-none-any.whl", hash = "sha256:e6b1d89711dd51f8f74b1631fe08f039e7d76cf67a42a323d3178f0f25762ed9", size = 45300, upload-time = "2025-08-24T12:55:53.394Z" },
]
+[[package]]
+name = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
[[package]]
name = "pytest"
version = "9.0.3"
@@ -1640,6 +2037,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
[[package]]
name = "sqlalchemy"
version = "2.0.49"
@@ -1704,6 +2110,18 @@ 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 = "tqdm"
+version = "4.68.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/05/0d5260f1f1ca784f4a4a0def9cbe6affe587f5b4025328d446c3d67765f4/tqdm-4.68.2.tar.gz", hash = "sha256:89c230e8dbc67c7615c142487111222f878c77427ea09549960f62389e258add", size = 171923, upload-time = "2026-06-09T13:26:42.539Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/75/1a0392bcc21c44dcdf87b3cf2d137e7829be2c083a1e38d44efca3d57a16/tqdm-4.68.2-py3-none-any.whl", hash = "sha256:d4240441fb5353290b87d6a85968c9decc131a99b8c7faa28269d829de669ede", size = 78578, upload-time = "2026-06-09T13:26:40.731Z" },
+]
+
[[package]]
name = "twine"
version = "6.2.0"
@@ -1769,6 +2187,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
]
+[[package]]
+name = "uritemplate"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
+]
+
[[package]]
name = "urllib3"
version = "2.6.3"