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"