diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0d82274..b80815f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,58 +2,48 @@ ## Overview -The application follows an **orchestrator pattern** where a central coordinator manages the turn lifecycle, delegating to stateless workers for LLM interaction and tool execution. +The application is structured as two modules: `orchestrator.py` handles the conversation loop, LLM streaming, and cancellation; `tools.py` handles API calls, retry logic, and response formatting. ```mermaid graph TD - A[main.py] -->|creates, configures logging, wires signals| B[Orchestrator] - B -->|OpenAI SDK streaming| F[LLM API] - B -->|dispatches tool calls| D[ToolExecutor] - B -->|renders output| E[Display] - D -->|httpx async + tenacity retry| G[Elyos Weather API] - D -->|httpx async + tenacity retry| H[Elyos Research API] + A[orchestrator.py] -->|entry point, turn loop, streaming| B[AsyncOpenAI] + A -->|dispatches tool calls| C[ToolExecutor] + B -->|OpenAI SDK| D[LLM API] + C -->|httpx async + retry loop| E[Elyos Weather API] + C -->|httpx async + retry loop| F[Elyos Research API] ``` ## Module Responsibilities -| Module | Role | Stateful? | -| ---------------- | ------------------------------------------------------- | --------- | -| `main.py` | Entry point, logging config, asyncio loop, SIGINT wiring | No | -| `orchestrator.py`| Turn lifecycle, conversation history, LLM streaming, cancellation | Yes | -| `tools.py` | API calls, tenacity retry, cancellable requests | No | -| `models.py` | Pydantic models for API responses, settings, tool results| No | -| `display.py` | Themed output (rich), spinners, tool indicators | No | +| Module | Role | Stateful? | +| ---------------- | ----------------------------------------------------------- | --------- | +| `orchestrator.py`| Entry point, logging, turn lifecycle, LLM streaming, cancel | Yes | +| `tools.py` | API calls, retry loop, cancellable requests, formatting | No | ## Turn Lifecycle ```mermaid sequenceDiagram participant U as User - participant O as Orchestrator - participant L as LLM API + participant O as _process_turn + participant S as _stream_response participant T as ToolExecutor - participant D as Display U->>O: input text - O->>L: stream(history) + O->>S: stream(history) loop streaming chunks - L-->>O: content delta / tool_call delta - O->>D: print_streaming_token() + S-->>O: content delta / tool_call delta + O-->>U: sys.stdout.write(token) end alt tool calls detected - O->>D: print_tool_call() - O->>D: tool_spinner() O->>T: execute(tool_call, cancel_event) - T-->>O: ToolResult - O->>D: print_tool_result_ok/error() - O->>L: stream(history + tool results) + T-->>O: result dict + O->>S: stream(history + tool results) loop streaming final response - O->>D: print_assistant_header() - L-->>O: content delta - O->>D: print_streaming_token() + S-->>O: content delta + O-->>U: sys.stdout.write(token) end end - O->>D: finish_streaming() ``` ## Cancellation Flow @@ -73,7 +63,7 @@ The signal handler is context-dependent: - **During input**: No custom SIGINT handler — `loop.add_reader(stdin)` races against `cancel_event.wait()`. Ctrl+C sets the cancel event, `_read_input()` returns `None`, and the app exits. - **During processing**: Custom handler is installed. 1st Ctrl+C sets `cancel_event`; 2nd sets `should_exit`. -- **During HTTP requests**: `_cancellable_request()` races the httpx coroutine against `cancel_event` via `asyncio.wait(FIRST_COMPLETED)`, so cancellation is instant even during slow API calls. +- **During HTTP requests**: `_race_with_cancel()` races the httpx coroutine against `cancel_event` via `asyncio.wait(FIRST_COMPLETED)`, so cancellation is instant even during slow API calls. - **On cleanup**: Signal handler is removed before `asyncio.run()` shutdown to avoid stale handlers. The cancel event is cleared at the start of each new turn. @@ -91,58 +81,41 @@ Using `asyncio.to_thread(input)` spawns a thread that blocks on `input()`. When ```mermaid flowchart TD REQ[API Request] --> CHECK{Response throttled?} - CHECK -->|No| PARSE[Parse response] + CHECK -->|No| PARSE[Parse + format response] CHECK -->|Yes| RETRY{Attempts < 3?} RETRY -->|Yes| WAIT[Wait retry_after_seconds] --> REQ - RETRY -->|No| ERR[_RateLimitError] + RETRY -->|No| ERR[RuntimeError] ``` -Retry is handled declaratively via a tenacity `@_throttle_retry` decorator: +Retry is handled by `_request_with_retry`, a simple loop: -- **Trigger**: `_ThrottledError` raised when API returns `status: "throttled"` -- **Wait**: Dynamic — reads `retry_after_seconds` from the throttled response (capped at 15s) +- **Trigger**: API returns `status: "throttled"` in JSON (HTTP 200) +- **Wait**: Reads `retry_after_seconds` from the throttled response (capped at 15s) - **Stop**: After 3 attempts -- **On exhaustion**: `retry_error_callback` converts to `_RateLimitError` -- **Logging**: `before_sleep` callback logs each retry with attempt count and wait time - -Both `_get_weather` and `_research_topic` use the same decorator. The method bodies contain only the happy path + throttle guard. +- **On exhaustion**: Raises `RuntimeError` with retry guidance +- **Cancellation**: Each wait is cancellable via `_wait_or_cancel` ## Data Flow ```mermaid flowchart LR subgraph API Responses - W1[Flat weather JSON] -->|from_api| WR[WeatherResponse] - W2[Array weather JSON] -->|from_api| WR - R1[Research JSON] --> RR[ResearchResponse] - T1[Throttled JSON] --> TE[_ThrottledError] + W1[Flat weather JSON] -->|_format_weather| S[String for LLM] + W2[Array weather JSON] -->|_format_weather| S + R1[Research JSON] -->|_format_research| S + T1[Throttled JSON] -->|_request_with_retry| W1 + T1 -->|_request_with_retry| R1 HTML[HTML error] --> DE[DecodingError] end - WR -->|display| S[String for LLM] - RR -->|display| S - TE -->|@_throttle_retry| W1 - TE -->|@_throttle_retry| R1 DE -->|error result| S ``` -## Display Theme - -| Element | Style | Usage | -| ---------- | ----------- | ---------------------------------------- | -| User | Bold cyan | `You:` prompt label | -| Assistant | Bold green | `Assistant:` header before streamed text | -| Tool call | Yellow | `⚡ tool_name(args)` indicator | -| Tool OK | Green | `✓ tool_name completed` | -| Tool error | Bold red | `✗ tool_name: message` | -| Separator | Dim | `────` rule between turns | -| Meta | Dim | `[cancelled]`, `Goodbye!` | - ## Key Design Decisions -1. **Orchestrator owns all state** — ToolExecutor and Display are stateless workers. This makes the system easy to reason about and test. -2. **Cancel via asyncio.Event** — shared between orchestrator and tool executor, checked cooperatively. HTTP requests are raced against the event for instant cancellation. -3. **Pydantic normalization** — `WeatherResponse.from_api()` handles the non-deterministic API schemas at the boundary, so downstream code always sees a consistent model. -4. **Tenacity for retry** — `@_throttle_retry` decorator with custom wait strategy reading `retry_after_seconds` from the API response. Keeps method bodies clean. -5. **Content-type guard** — `_request()` checks for `application/json` before parsing, handling infrastructure-level HTML errors (e.g., unicode input → Cloud Run 400). +1. **Flat functions over classes** — `_process_turn`, `_stream_response`, `_execute_tools` are plain async functions. State is just the `history` list and `cancel_event`, threaded through arguments. +2. **Cancel via asyncio.Event** — shared across the call chain, checked cooperatively. HTTP requests are raced against the event for instant cancellation. +3. **Dict-based formatting** — `_format_weather()` handles both flat and array API schemas with dict access. No Pydantic models needed for two simple response shapes. +4. **Simple retry loop** — `_request_with_retry` reads `retry_after_seconds` from the throttled response, sleeps (cancellably), and retries. Three attempts, capped at 15s wait. +5. **Content-type guard** — `_request()` checks for `application/json` before parsing, handling infrastructure-level HTML errors (e.g., unicode input causing Cloud Run 400). 6. **History integrity on cancel** — stub tool results ensure the conversation history is always valid, preventing LLM 400 errors after interrupted tool calls. -7. **File-only logging** — comprehensive DEBUG-level logs to timestamped session files, with third-party loggers silenced to WARNING. No console noise. +7. **File-only logging** — DEBUG-level logs to timestamped session files, with third-party loggers silenced to WARNING. No console noise. diff --git a/CLAUDE.md b/CLAUDE.md index 561ca03..48ec00d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,28 +7,28 @@ Take-home interview project: CLI chat app with streaming LLM + tool calling agai ## Quick reference - `make install` → `make run` to use -- `make check` runs lint + test (21 e2e tests) -- API keys in `.env` (LLM_API_KEY, ELYOS_API_KEY; optional LLM_BASE_URL, LLM_MODEL) +- `make check` runs lint + test (20 e2e tests) +- Env vars: LLM_API_KEY, ELYOS_API_KEY (required); LLM_BASE_URL, LLM_MODEL (optional). `uv run` auto-loads `.env` if present. - Logs: `cli_chat__.log` per session - Design docs: README.md, ARCHITECTURE.md, DISCOVERIES.md ## Key decisions -- Any OpenAI-compatible endpoint for LLM, configurable via LLM_BASE_URL and LLM_MODEL in .env +- Any OpenAI-compatible endpoint for LLM, configurable via LLM_BASE_URL and LLM_MODEL env vars - httpx async for external API calls, with `asyncio.wait` racing requests against cancel events -- Pydantic for all data models + settings -- Tenacity `@retry` decorator for throttle retry (custom wait from `retry_after_seconds`) -- Orchestrator pattern: separates turn lifecycle from chat/tool execution +- Plain dicts for API responses, formatted by `_format_weather` / `_format_research` helpers +- Simple retry loop for throttle handling (reads `retry_after_seconds`, max 3 attempts) +- Flat async functions: `_process_turn` → `_stream_response` → `_execute_tools` - Async stdin via `loop.add_reader` (not `asyncio.to_thread(input)`) — avoids dangling threads - Cancellation adds stub tool results to keep conversation history valid for the LLM -- Styled display via rich: cyan user, green assistant, yellow tools, red errors +- Rich spinner for tool call pending state - File-only logging (DEBUG level) with timestamped + UUID session files ## Ctrl+C behavior - During input: exits cleanly - During processing: first Ctrl+C cancels current operation, second exits -- During HTTP requests: instant cancellation via `_cancellable_request` (asyncio.wait race) +- During HTTP requests: instant cancellation via `_race_with_cancel` (asyncio.wait race) ## API quirks (see DISCOVERIES.md for details) diff --git a/DISCOVERIES.md b/DISCOVERIES.md index 443534e..c61e673 100644 --- a/DISCOVERIES.md +++ b/DISCOVERIES.md @@ -40,7 +40,7 @@ The API returns two different response shapes **randomly for the same city**. Co } ``` -**Handling:** `WeatherResponse.from_api()` detects the shape and normalizes both into a consistent `conditions` list. +**Handling:** `_format_weather` detects the shape — the flat case is wrapped into a single-element `conditions` list before rendering, so both shapes render identically. #### 2. Weather Also Rate-Limits (HTTP 200) @@ -371,9 +371,9 @@ Two distinct behaviors: Some inputs (observed with whitespace-only `" "` and XSS payloads) **occasionally return an empty `{}`** with HTTP 200. This is non-deterministic — the same input returns a normal response most of the time, but rarely returns `{}`. -**Impact:** Without a guard, `ResearchResponse(**{})` raises a Pydantic `ValidationError` (missing required `topic` and `summary`). +**Impact:** Formatting an empty dict would raise a `KeyError` on `summary`. -**Handling:** `_research_topic()` checks for empty or incomplete responses (missing `topic` or empty `summary`) before Pydantic parsing, returning a graceful "no results" message instead of crashing. +**Handling:** `_research_topic` checks for missing `topic` or empty `summary` before calling `_format_research`, returning a graceful "no results" message instead of crashing. #### 6. Standard Error Responses - `422` for missing `topic` param diff --git a/README.md b/README.md index f7ea5c1..dbb4163 100644 --- a/README.md +++ b/README.md @@ -6,23 +6,44 @@ A command-line chat application with streaming LLM responses and tool calling. B - Python 3.12+ - [uv](https://docs.astral.sh/uv/) -- API keys in `.env`: +- Environment variables: + - `LLM_API_KEY` (required) + - `ELYOS_API_KEY` (required) + - `LLM_BASE_URL` (optional, default: OpenRouter) + - `LLM_MODEL` (optional, default: `openai/gpt-4o-mini`) + + Set them however you prefer — exported in your shell, via CI secrets, or dropped into a local `.env` file, which `uv run` picks up automatically: ``` LLM_API_KEY=sk-... ELYOS_API_KEY=... ``` - Optional overrides: `LLM_BASE_URL` (default: OpenRouter), `LLM_MODEL` (default: `openai/gpt-4o-mini`) ## Setup +### With uv (recommended) + ```bash -make install +make install # runs uv sync +make run # runs uv run cli-chat +``` + +### Without uv (bare Python) + +```bash +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install . ``` ## Usage ```bash +# With uv make run + +# Without uv +source .venv/bin/activate +cli-chat ``` **Example session:** @@ -58,7 +79,7 @@ make check # lint + test (CI gate) ## Design -The application uses an **orchestrator pattern** — a central coordinator manages the conversation turn lifecycle, delegating to stateless workers for LLM streaming and tool execution. See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and detailed design rationale. +The application uses flat async functions to manage the conversation turn lifecycle, with a `ToolExecutor` class handling API calls. See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and detailed design rationale. **API quirks** discovered during development are documented in [DISCOVERIES.md](DISCOVERIES.md). @@ -66,9 +87,6 @@ The application uses an **orchestrator pattern** — a central coordinator manag ``` src/cli_chat/ -├── main.py # entry point, signal wiring -├── orchestrator.py # turn lifecycle, history, LLM streaming, cancellation -├── tools.py # API calls with retry + quirk handling -├── models.py # pydantic models (settings, responses) -└── display.py # streaming output, spinners +├── orchestrator.py # entry point, turn lifecycle, LLM streaming, cancellation +└── tools.py # API calls with retry, response formatting ``` diff --git a/pyproject.toml b/pyproject.toml index fc940ec..bc7e9d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,15 +6,12 @@ requires-python = ">=3.12" dependencies = [ "openai>=1.60.0", "httpx>=0.27.0", - "pydantic>=2.10.0", - "pydantic-settings>=2.7.0", "python-dotenv>=1.0.0", "rich>=13.9.0", - "tenacity>=9.0.0", ] [project.scripts] -cli-chat = "cli_chat.main:main" +cli-chat = "cli_chat.orchestrator:main" [build-system] requires = ["hatchling"] diff --git a/src/cli_chat/display.py b/src/cli_chat/display.py deleted file mode 100644 index 63b1b79..0000000 --- a/src/cli_chat/display.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Display helpers — styled output, spinners, and streaming.""" - -from __future__ import annotations - -import sys - -from rich.console import Console -from rich.live import Live -from rich.spinner import Spinner -from rich.theme import Theme - -_THEME = Theme( - { - "user": "bold cyan", - "assistant": "bold green", - "tool": "yellow", - "tool.name": "bold yellow", - "error": "bold red", - "meta": "dim", - } -) - -console = Console(theme=_THEME) - -# ANSI codes for raw stdout (used in input prompt where rich can't help) -_CYAN_BOLD = "\033[1;36m" -_RESET = "\033[0m" - - -def print_input_prompt() -> None: - """Print a separator rule and the colored 'You:' input prompt. - - The cursor stays on the same line so the user can type inline. - """ - console.print() - console.rule(style="meta") - sys.stdout.write(f"{_CYAN_BOLD}You:{_RESET} ") - sys.stdout.flush() - - -def print_assistant_header() -> None: - """Print the styled 'Assistant:' header before a streamed response.""" - console.print() - console.print("Assistant:", style="assistant") - - -def print_streaming_token(token: str) -> None: - """Write a single streaming token to stdout and flush immediately. - - Args: - token: The text fragment to display. - """ - sys.stdout.write(token) - sys.stdout.flush() - - -def finish_streaming() -> None: - """Write a trailing newline after a streamed response completes.""" - sys.stdout.write("\n") - sys.stdout.flush() - - -def print_tool_call(tool_name: str, args: dict) -> None: - """Print a styled line indicating which tool is being invoked. - - Args: - tool_name: Name of the tool (e.g. ``get_weather``). - args: Parsed arguments passed to the tool. - """ - if tool_name == "get_weather": - detail = args.get("location", "?") - elif tool_name == "research_topic": - detail = args.get("topic", "?") - else: - detail = str(args) - console.print(f" [tool]⚡ [tool.name]{tool_name}[/tool.name]({detail})[/tool]") - - -def tool_spinner(tool_name: str, args: dict) -> Live: - """Create a transient Rich Live spinner for the duration of a tool call. - - Intended to be used as a context manager (``with tool_spinner(...)``). - - Args: - tool_name: Name of the tool being executed. - args: Parsed arguments passed to the tool, used to build the label. - - Returns: - A ``rich.live.Live`` instance wrapping a dots spinner. - """ - if tool_name == "get_weather": - label = f"Getting weather for {args.get('location', '?')}..." - elif tool_name == "research_topic": - label = f"Researching {args.get('topic', '?')}... (Ctrl+C to cancel)" - else: - label = f"Running {tool_name}..." - spinner = Spinner("dots", text=f"[tool]{label}[/tool]") - return Live(spinner, console=console, transient=True) - - -def print_tool_result_ok(tool_name: str) -> None: - """Print a green check mark indicating a tool call succeeded. - - Args: - tool_name: Name of the tool that completed. - """ - console.print(f" [green]✓[/green] [meta]{tool_name} completed[/meta]") - - -def print_tool_result_error(tool_name: str, message: str) -> None: - """Print a red error indicator for a failed tool call. - - Args: - tool_name: Name of the tool that failed. - message: Error description to display. - """ - console.print(f" [error]✗ {tool_name}:[/error] {message}") - - -def print_error(msg: str) -> None: - """Print a general error message in bold red. - - Args: - msg: The error text to display. - """ - console.print(f"[error]{msg}[/error]") - - -def print_dim(msg: str) -> None: - """Print dimmed/muted text for secondary information. - - Args: - msg: The text to display in dim style. - """ - console.print(f"[meta]{msg}[/meta]") diff --git a/src/cli_chat/main.py b/src/cli_chat/main.py deleted file mode 100644 index 4259774..0000000 --- a/src/cli_chat/main.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Entry point — CLI setup, logging configuration, and signal wiring.""" - -from __future__ import annotations - -import asyncio -import datetime -import logging -import signal -import uuid - -from cli_chat.display import console, print_dim -from cli_chat.models import Settings -from cli_chat.orchestrator import Orchestrator - -logger = logging.getLogger(__name__) - - -def _configure_logging() -> str: - """Set up file-only DEBUG logging with a unique session filename. - - Noisy third-party loggers (httpx, openai, httpcore) are suppressed to - WARNING level. - - Returns: - The path to the created log file. - """ - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - session_id = uuid.uuid4().hex[:8] - log_file = f"cli_chat_{timestamp}_{session_id}.log" - logging.basicConfig( - filename=log_file, - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - for noisy in ("httpx", "openai", "httpcore"): - logging.getLogger(noisy).setLevel(logging.WARNING) - return log_file - - -async def _run() -> None: - """Initialize settings, logging, and signal handling, then start the orchestrator.""" - settings = Settings() # type: ignore[call-arg] - log_file = _configure_logging() - logger.info("Session started (model=%s, log_file=%s)", settings.llm_model, log_file) - - orch = Orchestrator(settings) - loop = asyncio.get_running_loop() - loop.add_signal_handler(signal.SIGINT, orch.handle_interrupt) - - console.print("[bold]CLI Chat[/bold] — type 'exit' to quit, Ctrl+C to cancel\n") - - try: - await orch.run() - finally: - loop.remove_signal_handler(signal.SIGINT) - await orch.close() - print_dim("\nGoodbye!") - logger.info("Session ended") - - -def main() -> None: - """CLI entry point that runs the async application loop.""" - asyncio.run(_run()) - - -if __name__ == "__main__": - main() diff --git a/src/cli_chat/models.py b/src/cli_chat/models.py deleted file mode 100644 index f6eac3f..0000000 --- a/src/cli_chat/models.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Pydantic models for API responses and application state.""" - -from __future__ import annotations - -from pydantic import BaseModel, Field -from pydantic_settings import BaseSettings - - -class Settings(BaseSettings): - model_config = {"env_file": ".env", "extra": "ignore"} - - llm_api_key: str = Field(alias="LLM_API_KEY") - llm_base_url: str = Field(default="https://openrouter.ai/api/v1", alias="LLM_BASE_URL") - llm_model: str = Field(default="openai/gpt-4o-mini", alias="LLM_MODEL") - elyos_api_key: str = Field(alias="ELYOS_API_KEY") - elyos_base_url: str = "https://elyos-interview-907656039105.europe-west2.run.app" - - -class WeatherCondition(BaseModel): - temperature_c: float - condition: str - humidity: float - - -class WeatherResponse(BaseModel): - """Normalized weather response — always uses a list of conditions.""" - - location: str - conditions: list[WeatherCondition] - note: str | None = None - - @classmethod - def from_api(cls, data: dict) -> WeatherResponse: - """Normalize a raw API dict into a ``WeatherResponse``. - - Handles both the flat single-condition shape and the array - ``conditions`` shape that the weather API returns - non-deterministically. - - Args: - data: Raw JSON dict from the weather API. - - Returns: - A normalized ``WeatherResponse`` with a conditions list. - """ - if "conditions" in data: - return cls(**data) - return cls( - location=data["location"], - conditions=[ - WeatherCondition( - temperature_c=data["temperature_c"], condition=data["condition"], humidity=data["humidity"] - ) - ], - ) - - def display(self) -> str: - """Format the weather data as a human-readable multi-line string. - - Returns: - Formatted weather summary including location, conditions, - and optional note. - """ - parts = [f"Weather in {self.location}:"] - for c in self.conditions: - parts.append(f" {c.condition}, {c.temperature_c}°C, {c.humidity}% humidity") - if self.note: - parts.append(f" Note: {self.note}") - return "\n".join(parts) - - -class ResearchResponse(BaseModel): - topic: str - summary: str - sources: list[str] = [] - generated_at: str | None = None - cached: bool = False - cache_age_seconds: int | None = None - - def display(self) -> str: - """Format the research result as a human-readable multi-line string. - - Includes sources when available, and a staleness warning for - cached results. - - Returns: - Formatted research summary. - """ - parts = [self.summary] - if self.sources: - parts.append(f"Sources: {', '.join(self.sources)}") - if self.cached and self.cache_age_seconds is not None: - parts.append(f"Note: cached result ({self.cache_age_seconds // 86400} days old)") - return "\n".join(parts) - - -class ThrottledResponse(BaseModel): - status: str # "throttled" - message: str - retry_after_seconds: int - data: None = None - - -class ToolResult(BaseModel): - tool_call_id: str - name: str - content: str - error: bool = False diff --git a/src/cli_chat/orchestrator.py b/src/cli_chat/orchestrator.py index c7b4b38..ec72061 100644 --- a/src/cli_chat/orchestrator.py +++ b/src/cli_chat/orchestrator.py @@ -1,338 +1,246 @@ -"""Orchestrator — manages the turn lifecycle, history, and cancellation.""" - from __future__ import annotations import asyncio import contextlib +import datetime import json import logging +import os +import signal import sys -from typing import TYPE_CHECKING +import uuid from openai import AsyncOpenAI -from openai.types.chat import ( - ChatCompletionAssistantMessageParam, - ChatCompletionToolMessageParam, - ChatCompletionUserMessageParam, -) -from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function +from rich.live import Live +from rich.spinner import Spinner -from cli_chat.display import ( - finish_streaming, - print_assistant_header, - print_dim, - print_error, - print_input_prompt, - print_streaming_token, - print_tool_call, - print_tool_result_error, - print_tool_result_ok, - tool_spinner, -) from cli_chat.tools import TOOL_DEFINITIONS, ToolExecutor -if TYPE_CHECKING: - from openai.types.chat import ChatCompletionMessageParam - - from cli_chat.models import Settings, ToolResult - -logger = logging.getLogger(__name__) - SYSTEM_PROMPT = "You are a helpful assistant. Be concise and helpful." +DEFAULT_LLM_BASE_URL = "https://openrouter.ai/api/v1" +DEFAULT_ELYOS_BASE_URL = "https://elyos-interview-907656039105.europe-west2.run.app" +DEFAULT_MODEL = "openai/gpt-4o-mini" +logger = logging.getLogger(__name__) -class Orchestrator: - """Manages the chat turn lifecycle, conversation history, and cancellation.""" - - def __init__(self, settings: Settings) -> None: - """Initialize the orchestrator with an LLM client and tool executor. - - Args: - settings: Application settings containing LLM and API - configuration. - """ - self._client = AsyncOpenAI(api_key=settings.llm_api_key, base_url=settings.llm_base_url) - self._model = settings.llm_model - self._tools = ToolExecutor(settings) - self._history: list[ChatCompletionMessageParam] = [] - self._cancel_event = asyncio.Event() - self._should_exit = False - self._turn_count = 0 - - async def close(self) -> None: - """Shut down the tool executor and release resources.""" - logger.info("Orchestrator closing (turns=%d, history_len=%d)", self._turn_count, len(self._history)) - await self._tools.close() - - def handle_interrupt(self) -> None: - """Handle a SIGINT signal. - - First invocation cancels the current operation. Second invocation - requests a full exit from the run loop. - """ - if self._cancel_event.is_set(): - logger.info("SIGINT: second interrupt, requesting exit") - self._should_exit = True +def _configure_logging() -> str: + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + session_id = uuid.uuid4().hex[:8] + log_file = f"cli_chat_{timestamp}_{session_id}.log" + logging.basicConfig( + filename=log_file, + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + for noisy in ("httpx", "openai", "httpcore"): + logging.getLogger(noisy).setLevel(logging.WARNING) + return log_file +def _load_config() -> tuple[str, str, str, str, str]: + return ( + os.environ["LLM_API_KEY"], + os.getenv("LLM_BASE_URL", DEFAULT_LLM_BASE_URL), + os.getenv("LLM_MODEL", DEFAULT_MODEL), + os.environ["ELYOS_API_KEY"], + os.getenv("ELYOS_BASE_URL", DEFAULT_ELYOS_BASE_URL), + ) + +async def _read_input(cancel_event: asyncio.Event) -> str | None: + loop = asyncio.get_running_loop() + print(flush=True) + sys.stdout.write("You: ") + sys.stdout.flush() + line_future: asyncio.Future[str] = loop.create_future() + + def _on_stdin_ready() -> None: + if not line_future.done(): + line_future.set_result(sys.stdin.readline()) + loop.add_reader(sys.stdin.fileno(), _on_stdin_ready) + cancel_task = asyncio.create_task(cancel_event.wait()) + try: + await asyncio.wait({line_future, cancel_task}, return_when=asyncio.FIRST_COMPLETED) + finally: + loop.remove_reader(sys.stdin.fileno()) + cancel_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await cancel_task + if not line_future.done(): + return None + line = line_future.result() + return line.rstrip("\n") if line else None + + +def _merge_tool_call_delta(tool_calls_by_index: dict[int, dict], tc_delta) -> None: + entry = tool_calls_by_index.setdefault( + tc_delta.index, + {"id": tc_delta.id or "", "type": "function", "function": {"name": "", "arguments": ""}}, + ) + if tc_delta.id: + entry["id"] = tc_delta.id + if not tc_delta.function: + return + if tc_delta.function.name: + entry["function"]["name"] = tc_delta.function.name + if tc_delta.function.arguments: + entry["function"]["arguments"] += tc_delta.function.arguments + +async def _stream_response( # pylint: disable=too-many-branches + client: AsyncOpenAI, + model: str, + history: list[dict], + cancel_event: asyncio.Event, +) -> tuple[str, list[dict]] | None: + logger.info("LLM stream request (model=%s, messages=%d)", model, len(history)) + try: + messages = [{"role": "system", "content": SYSTEM_PROMPT}, *history] + stream = await client.chat.completions.create( # pyright: ignore[reportCallIssue, reportArgumentType] + model=model, messages=messages, tools=TOOL_DEFINITIONS, stream=True, # pyright: ignore[reportArgumentType] + ) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error("LLM stream creation failed: %s", exc, exc_info=True) + print(f"Error: LLM error: {exc}", file=sys.stderr) + return None + content = "" + tool_calls_by_index: dict[int, dict] = {} + header_printed = False + try: + async for chunk in stream: + if cancel_event.is_set(): + logger.info("Stream cancelled by user (content so far: %d chars)", len(content)) + await stream.close() + if content: + sys.stdout.write("\n[cancelled]\n") + else: + print("[cancelled]") + return content, [] + delta = chunk.choices[0].delta if chunk.choices else None + if delta is None: + continue + if delta.content: + if not header_printed: + print("\nAssistant:") + header_printed = True + content += delta.content + sys.stdout.write(delta.content) + sys.stdout.flush() + if delta.tool_calls: + for tc_delta in delta.tool_calls: + _merge_tool_call_delta(tool_calls_by_index, tc_delta) + except Exception as exc: # pylint: disable=broad-exception-caught + logger.error("Stream error: %s", exc, exc_info=True) + print(f"\nError: Stream error: {exc}", file=sys.stderr) + return None + logger.debug("Stream completed: %d chars content, %d tool calls", len(content), len(tool_calls_by_index)) + return content, list(tool_calls_by_index.values()) + + +async def _execute_tools( + tools: ToolExecutor, + tool_calls: list[dict], + cancel_event: asyncio.Event, +) -> list[dict] | None: + results = [] + for tool_call in tool_calls: + if cancel_event.is_set(): + print("[cancelled]") + return None + name = tool_call["function"]["name"] + args = {} + with contextlib.suppress(json.JSONDecodeError): + args = json.loads(tool_call["function"]["arguments"] or "{}") + if name == "research_topic": + topic = args.get("topic", "").strip() or "topic" + print(f"Researching {topic}... (Ctrl+C to cancel)") else: - logger.info("SIGINT: cancelling current operation") - self._cancel_event.set() - - async def run(self) -> None: - """Run the main read-eval-print loop until the user exits. - - Reads user input, processes each turn through the LLM, and - handles exit commands (``exit``, ``quit``, EOF, Ctrl+C). - """ - while not self._should_exit: - self._cancel_event.clear() - user_input = await self._read_input() + print(f"Calling {name}({args})") + with Live(Spinner("dots", text=f"{name}..."), transient=True): + result = await tools.execute(tool_call, cancel_event) + if result["error"]: + print(f"{name} failed: {result['content']}") + results.append(result) + return results + +async def _process_turn( # pylint: disable=too-many-arguments,too-many-positional-arguments + client: AsyncOpenAI, + model: str, + tools: ToolExecutor, + history: list[dict], + user_input: str, + cancel_event: asyncio.Event, +) -> None: + logger.info("User input: %s", user_input) + rollback_point = len(history) + history.append({"role": "user", "content": user_input}) + while True: + result = await _stream_response(client, model, history, cancel_event) + if result is None: + logger.info("Rolling back turn (%d entries) after stream failure", len(history) - rollback_point) + del history[rollback_point:] + return + content, tool_calls = result + if cancel_event.is_set(): + if content: + logger.info("Saved partial response (%d chars)", len(content)) + history.append({"role": "assistant", "content": content + "\n[cancelled]"}) + return + assistant_message: dict = {"role": "assistant"} + if content: + assistant_message["content"] = content + if tool_calls: + assistant_message["tool_calls"] = tool_calls # pyright: ignore[reportArgumentType] + history.append(assistant_message) + if not tool_calls: + sys.stdout.write("\n") + sys.stdout.flush() + return + tool_results = await _execute_tools(tools, tool_calls, cancel_event) + if tool_results is None: + for tc in tool_calls: + history.append({"role": "tool", "tool_call_id": tc["id"], "content": "[cancelled by user]"}) + return + for r in tool_results: + history.append({"role": "tool", "tool_call_id": r["tool_call_id"], "content": r["content"]}) + +async def run_chat() -> None: + llm_api_key, llm_base_url, model, elyos_api_key, elyos_base_url = _load_config() + log_file = _configure_logging() + client = AsyncOpenAI(api_key=llm_api_key, base_url=llm_base_url) + tools = ToolExecutor(elyos_base_url, elyos_api_key) + history: list[dict] = [] + cancel_event = asyncio.Event() + state = {"should_exit": False} + + def _handle_interrupt() -> None: + if cancel_event.is_set(): + state["should_exit"] = True + else: + cancel_event.set() + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, _handle_interrupt) + logger.info("Session started (model=%s, log_file=%s)", model, log_file) + print("CLI Chat — type 'exit' to quit, Ctrl+C to cancel\n") + try: + while not state["should_exit"]: + cancel_event.clear() + user_input = await _read_input(cancel_event) if user_input is None: - logger.info("Input cancelled or EOF, exiting loop") break - user_input = user_input.strip() if not user_input: continue - if user_input.lower() in ("exit", "quit"): - logger.info("User requested exit via '%s'", user_input) - break - - self._turn_count += 1 - logger.info("Turn %d: user input: %s", self._turn_count, user_input) - await self._process_turn(user_input) - - async def _read_input(self) -> str | None: - """Read a line from stdin using ``loop.add_reader``, without threads. - - The read is raced against the cancel event so Ctrl+C returns - immediately instead of blocking. - - Returns: - The stripped input line, or ``None`` if cancelled or EOF. - """ - loop = asyncio.get_running_loop() - print_input_prompt() - - line_future: asyncio.Future[str] = loop.create_future() - - def _on_stdin_ready() -> None: - """Callback fired when stdin has data ready to read.""" - if not line_future.done(): - line_future.set_result(sys.stdin.readline()) - - fd = sys.stdin.fileno() - loop.add_reader(fd, _on_stdin_ready) - cancel_task = asyncio.create_task(self._cancel_event.wait()) - - try: - await asyncio.wait({line_future, cancel_task}, return_when=asyncio.FIRST_COMPLETED) - finally: - loop.remove_reader(fd) - if not cancel_task.done(): - cancel_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await cancel_task - - if not line_future.done(): - return None - line = line_future.result() - return line.rstrip("\n") if line else None - - async def _process_turn(self, user_input: str) -> None: - """Process a single conversation turn. - - Streams the LLM response, executes any requested tool calls, and - loops until the assistant produces a final text reply or the - operation is cancelled. - - Args: - user_input: The user's message text for this turn. - """ - rollback_point = len(self._history) - self._history.append(ChatCompletionUserMessageParam(role="user", content=user_input)) - - while not self._should_exit: - result = await self._stream_response() - if result is None: - # Infra failure: roll back so the next turn starts from a clean history, - # instead of leaving a dangling user message or partial tool_call/result pairs. - logger.info( - "Turn %d: rolling back %d history entries after stream failure", - self._turn_count, - len(self._history) - rollback_point, - ) - del self._history[rollback_point:] + if user_input.lower() in {"exit", "quit"}: break + await _process_turn(client, model, tools, history, user_input, cancel_event) + finally: + loop.remove_signal_handler(signal.SIGINT) + await tools.close() + print("\nGoodbye!") + logger.info("Session ended") - content, tool_calls = result - - # Cancelled mid-stream: save partial content to history and stop - if self._cancel_event.is_set(): - if content: - self._append_assistant_message(content + "\n[cancelled]", []) - logger.info("Turn %d: saved partial response (%d chars)", self._turn_count, len(content)) - break - - self._append_assistant_message(content, tool_calls) - - if not tool_calls: - finish_streaming() - logger.info("Turn %d: assistant responded (%d chars)", self._turn_count, len(content)) - break - - logger.info( - "Turn %d: LLM requested %d tool call(s): %s", - self._turn_count, - len(tool_calls), - [tc.function.name for tc in tool_calls], - ) - - tool_results = await self._execute_tools(tool_calls) - if tool_results is None: - # Cancelled: add stub tool results so history stays valid for the LLM - for tc in tool_calls: - self._history.append( - ChatCompletionToolMessageParam( - role="tool", tool_call_id=tc.id, content="[cancelled by user]" - ) - ) - break - - for tr in tool_results: - self._history.append( - ChatCompletionToolMessageParam(role="tool", tool_call_id=tr.tool_call_id, content=tr.content) - ) - - async def _stream_response(self) -> tuple[str, list[ChatCompletionMessageToolCall]] | None: # pylint: disable=too-many-branches - """Stream a chat completion from the LLM and collect the result. - - Prints tokens to stdout as they arrive. Handles mid-stream - cancellation by closing the stream and returning partial content. - - Returns: - A tuple of ``(content, tool_calls)`` on success, or ``None`` - if a fatal error occurs during streaming. - """ - try: - logger.info("LLM stream request (model=%s, messages=%d)", self._model, len(self._history)) - stream = await self._client.chat.completions.create( - model=self._model, - messages=[{"role": "system", "content": SYSTEM_PROMPT}, *self._history], - tools=TOOL_DEFINITIONS, # type: ignore[arg-type] - stream=True, - ) - except Exception as exc: # pylint: disable=broad-exception-caught - logger.error("LLM stream creation failed: %s", exc, exc_info=True) - print_error(f"LLM error: {exc}") - return None - - content = "" - tool_calls_by_index: dict[int, dict] = {} - header_printed = False - - try: - async for chunk in stream: - if self._cancel_event.is_set(): - logger.info("Stream cancelled by user (content so far: %d chars)", len(content)) - await stream.close() - if content: - finish_streaming() - print_dim("[cancelled]") - else: - print_dim("\n[cancelled]") - # Return partial content so it can be saved to history - return content, [] - - delta = chunk.choices[0].delta if chunk.choices else None - if delta is None: - continue - - if delta.content: - if not header_printed: - print_assistant_header() - header_printed = True - content += delta.content - print_streaming_token(delta.content) - - if delta.tool_calls: - for tc_delta in delta.tool_calls: - idx = tc_delta.index - if idx not in tool_calls_by_index: - tool_calls_by_index[idx] = {"id": tc_delta.id or "", "name": "", "arguments": ""} - entry = tool_calls_by_index[idx] - if tc_delta.id: - entry["id"] = tc_delta.id - if tc_delta.function: - if tc_delta.function.name: - entry["name"] = tc_delta.function.name - if tc_delta.function.arguments: - entry["arguments"] += tc_delta.function.arguments - - except Exception as exc: # pylint: disable=broad-exception-caught - logger.error("Stream error: %s", exc, exc_info=True) - print_error(f"\nStream error: {exc}") - return None - - logger.debug("Stream completed: %d chars content, %d tool calls", len(content), len(tool_calls_by_index)) - - tool_calls = [ - ChatCompletionMessageToolCall( - id=tc["id"], type="function", function=Function(name=tc["name"], arguments=tc["arguments"]) - ) - for tc in tool_calls_by_index.values() - ] - return content, tool_calls - async def _execute_tools(self, tool_calls: list[ChatCompletionMessageToolCall]) -> list[ToolResult] | None: - """Execute a list of tool calls sequentially with cancellation checks. +def main() -> None: + asyncio.run(run_chat()) - Args: - tool_calls: Tool calls requested by the LLM. - Returns: - List of ``ToolResult`` objects, or ``None`` if cancelled - before all tools complete. - """ - results: list[ToolResult] = [] - for tc in tool_calls: - if self._cancel_event.is_set(): - logger.info("Tool execution cancelled before %s", tc.function.name) - print_dim("[cancelled]") - return None - - args = {} - with contextlib.suppress(json.JSONDecodeError): - args = json.loads(tc.function.arguments) - - print_tool_call(tc.function.name, args) - with tool_spinner(tc.function.name, args): - result = await self._tools.execute(tc, self._cancel_event) - - if result.error: - print_tool_result_error(tc.function.name, result.content) - else: - print_tool_result_ok(tc.function.name) - results.append(result) - - return results - - def _append_assistant_message(self, content: str, tool_calls: list[ChatCompletionMessageToolCall]) -> None: - """Append an assistant message to the conversation history. - - Args: - content: The assistant's text response (may be empty). - tool_calls: Tool calls the assistant requested (may be empty). - """ - msg = ChatCompletionAssistantMessageParam(role="assistant") - if content: - msg["content"] = content - if tool_calls: - msg["tool_calls"] = [ - { - "id": tc.id, - "type": "function", - "function": {"name": tc.function.name, "arguments": tc.function.arguments}, - } - for tc in tool_calls - ] - self._history.append(msg) +if __name__ == "__main__": + main() diff --git a/src/cli_chat/tools.py b/src/cli_chat/tools.py index 5c53a62..ead8da7 100644 --- a/src/cli_chat/tools.py +++ b/src/cli_chat/tools.py @@ -1,21 +1,11 @@ -"""Tool executor — weather and research API calls with retry and quirk handling.""" - from __future__ import annotations import asyncio import contextlib import json import logging -import typing -from typing import TYPE_CHECKING import httpx -from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt - -from cli_chat import models - -if TYPE_CHECKING: - from openai.types.chat import chat_completion_message_tool_call as tc_module logger = logging.getLogger(__name__) @@ -46,306 +36,156 @@ }, ] +# Set above the research API's 15s server-side timeout (DISCOVERIES.md) so the +# server's empty-response path always wins the race and we never raise our own +# httpx.ReadTimeout (which can carry an empty message and looks like an infra bug). +REQUEST_TIMEOUT = 20.0 MAX_RETRIES = 3 -REQUEST_TIMEOUT = 15.0 MAX_THROTTLE_WAIT = 15 -# ── Retry infrastructure ───────────────────────────────────────────────────────────────────────── - - -class _ThrottledError(Exception): - """Raised when the API returns a throttled response (HTTP 200).""" - - def __init__(self, retry_after: int, endpoint: str) -> None: - """Initialize with retry timing and the throttled endpoint name. - - Args: - retry_after: Seconds the API asks us to wait before retrying. - endpoint: Human-readable name of the throttled endpoint - (e.g. ``"Weather"``). - """ - super().__init__(f"{endpoint} throttled, retry after {retry_after}s") - self.retry_after = retry_after - self.endpoint = endpoint - - -class _RateLimitError(Exception): - """Raised when retries are exhausted due to API rate limiting.""" - - -def _throttle_wait(retry_state: RetryCallState) -> float: - """Compute the wait time before the next retry attempt. - - Uses the ``retry_after`` value from a ``_ThrottledError``, capped at - ``MAX_THROTTLE_WAIT``. Falls back to 1 second for other exceptions. - - Args: - retry_state: Tenacity retry state containing the failed outcome. - - Returns: - Number of seconds to wait before retrying. - """ - exc = retry_state.outcome.exception() # type: ignore[union-attr] - return min(exc.retry_after, MAX_THROTTLE_WAIT) if isinstance(exc, _ThrottledError) else 1 - - -def _log_before_retry(retry_state: RetryCallState) -> None: - """Log a warning before each throttle retry attempt. - - Args: - retry_state: Tenacity retry state containing the failed outcome. - """ - exc = retry_state.outcome.exception() # type: ignore[union-attr] - if isinstance(exc, _ThrottledError): - logger.warning( - "%s throttled (attempt %d/%d), retrying in %ds", - exc.endpoint, - retry_state.attempt_number, - MAX_RETRIES, - min(exc.retry_after, MAX_THROTTLE_WAIT), +def _format_weather(data: dict) -> str: + conditions = data.get("conditions") + if not conditions: + conditions = [ + { + "temperature_c": data["temperature_c"], + "condition": data["condition"], + "humidity": data["humidity"], + } + ] + + parts = [f"Weather in {data['location']}:"] + for condition in conditions: + parts.append( + f" {condition['condition']}, {condition['temperature_c']}°C, {condition['humidity']}% humidity" ) + if data.get("note"): + parts.append(f" Note: {data['note']}") + return "\n".join(parts) +def _format_research(data: dict) -> str: + parts = [data.get("summary", "No research summary returned.")] + if data.get("sources"): + parts.append(f"Sources: {', '.join(data['sources'])}") + if data.get("cached") and data.get("cache_age_seconds") is not None: + days_old = data["cache_age_seconds"] // 86400 + parts.append(f"Note: cached result ({days_old} days old)") + return "\n".join(parts) -def _on_retries_exhausted(retry_state: RetryCallState) -> typing.NoReturn: - """Raise a ``_RateLimitError`` after all retry attempts are exhausted. - - Args: - retry_state: Tenacity retry state containing the final failed outcome. - Raises: - _RateLimitError: Always raised with details from the last failure. - """ - exc = retry_state.outcome.exception() # type: ignore[union-attr] - if isinstance(exc, _ThrottledError): - raise _RateLimitError(f"{exc.endpoint} API is rate-limited. Please try again in {exc.retry_after}s.") from exc - raise _RateLimitError("Request failed after retries.") - - -_throttle_retry = retry( - retry=retry_if_exception_type(_ThrottledError), - wait=_throttle_wait, # type: ignore[arg-type] - stop=stop_after_attempt(MAX_RETRIES), - before_sleep=_log_before_retry, # type: ignore[arg-type] - retry_error_callback=_on_retries_exhausted, # type: ignore[arg-type] -) +def _tool_result(tool_call_id: str, content: str, error: bool = False) -> dict: + return {"tool_call_id": tool_call_id, "content": content, "error": error} class ToolExecutor: - """Dispatches tool calls to the appropriate API endpoint with retry logic.""" - - def __init__(self, settings: models.Settings) -> None: - """Initialize the executor with an httpx client configured from settings. - - Args: - settings: Application settings containing API URLs and keys. - """ - self._settings = settings + def __init__(self, base_url: str, api_key: str) -> None: self._client = httpx.AsyncClient( - base_url=settings.elyos_base_url, - headers={"X-API-Key": settings.elyos_api_key}, + base_url=base_url, + headers={"X-API-Key": api_key}, timeout=REQUEST_TIMEOUT, ) async def close(self) -> None: - """Close the underlying httpx client and release connections.""" await self._client.aclose() - async def execute( - self, - tool_call: tc_module.ChatCompletionMessageToolCall, - cancel_event: asyncio.Event | None = None, - ) -> models.ToolResult: - """Execute a single tool call and return the result. - - Dispatches to the appropriate API method based on the tool name. - Handles JSON parse errors, HTTP errors, cancellation, and rate - limiting, wrapping all outcomes in a ``ToolResult``. - - Args: - tool_call: The LLM-generated tool call to execute. - cancel_event: Optional event that, when set, cancels the - in-flight request. - - Returns: - A ``ToolResult`` containing the tool output or an error message. - """ - name = tool_call.function.name + async def _execute_named_tool(self, name: str, args: dict, cancel_event: asyncio.Event) -> str: + if name == "get_weather": + return await self._get_weather(args.get("location", ""), cancel_event) + if name == "research_topic": + return await self._research_topic(args.get("topic", ""), cancel_event) + logger.warning("Unknown tool requested: %s", name) + return f"Unknown tool: {name}" + + async def execute(self, tool_call: dict, cancel_event: asyncio.Event) -> dict: + tool_call_id = tool_call["id"] + name = tool_call["function"]["name"] try: - args = json.loads(tool_call.function.arguments) + args = json.loads(tool_call["function"]["arguments"] or "{}") except json.JSONDecodeError: - logger.error("Invalid JSON arguments for tool %s: %s", name, tool_call.function.arguments) - return models.ToolResult( - tool_call_id=tool_call.id, name=name, content="Error: invalid tool arguments", error=True - ) + logger.error("Invalid JSON arguments for tool %s: %s", name, tool_call["function"]["arguments"]) + return _tool_result(tool_call_id, "Error: invalid tool arguments", error=True) logger.info("Tool call: %s(%s)", name, args) - try: - if name == "get_weather": - content = await self._get_weather(args.get("location", ""), cancel_event) - elif name == "research_topic": - content = await self._research_topic(args.get("topic", ""), cancel_event) - else: - logger.warning("Unknown tool requested: %s", name) - content = f"Unknown tool: {name}" - logger.info("Tool %s completed successfully", name) + content = await self._execute_named_tool(name, args, cancel_event) + logger.info("Tool %s completed", name) logger.debug("Tool %s result: %s", name, content[:200]) - return models.ToolResult(tool_call_id=tool_call.id, name=name, content=content) + return _tool_result(tool_call_id, content) except asyncio.CancelledError: logger.warning("Tool %s cancelled by user", name) - return models.ToolResult( - tool_call_id=tool_call.id, name=name, content="Tool call was cancelled by the user.", error=True - ) + return _tool_result(tool_call_id, "Tool call was cancelled by the user.", error=True) except httpx.HTTPStatusError as exc: logger.error("Tool %s HTTP error %d: %s", name, exc.response.status_code, exc.response.text[:200]) - return models.ToolResult( - tool_call_id=tool_call.id, - name=name, - content=f"API error ({exc.response.status_code}): {exc.response.text}", - error=True, - ) - except (httpx.RequestError, httpx.TimeoutException, httpx.DecodingError) as exc: - logger.error("Tool %s request failed: %s", name, exc) - return models.ToolResult(tool_call_id=tool_call.id, name=name, content=f"Request failed: {exc}", error=True) - except _RateLimitError as exc: - logger.warning("Tool %s rate-limited after %d retries: %s", name, MAX_RETRIES, exc) - return models.ToolResult(tool_call_id=tool_call.id, name=name, content=str(exc), error=True) - - @_throttle_retry - async def _get_weather(self, location: str, cancel_event: asyncio.Event | None) -> str: - """Fetch weather data for a location from the Elyos API. - - Retries automatically on throttled responses. - - Args: - location: City name to look up. - cancel_event: Optional cancellation event. - - Returns: - Formatted weather display string. - - Raises: - _ThrottledError: When the API returns a throttled response - (caught by the retry decorator). - """ - resp = await self._request("/weather", {"location": location}, cancel_event) - if resp.get("status") == "throttled": - raise _ThrottledError(models.ThrottledResponse(**resp).retry_after_seconds, "Weather") - weather = models.WeatherResponse.from_api(resp) - logger.debug("Weather response format: %s", "array" if "conditions" in resp else "flat") - return weather.display() - - @_throttle_retry - async def _research_topic(self, topic: str, cancel_event: asyncio.Event | None) -> str: - """Fetch research data for a topic from the Elyos API. - - Retries automatically on throttled responses. - - Args: - topic: Subject to research. - cancel_event: Optional cancellation event. - - Returns: - Formatted research display string. - - Raises: - _ThrottledError: When the API returns a throttled response - (caught by the retry decorator). - """ - resp = await self._request("/research", {"topic": topic}, cancel_event) - if resp.get("status") == "throttled": - raise _ThrottledError(models.ThrottledResponse(**resp).retry_after_seconds, "Research") - if not resp or "topic" not in resp or not resp.get("summary"): - logger.warning("Research returned empty/incomplete response: %s", resp) + msg = f"API error ({exc.response.status_code}): {exc.response.text}" + return _tool_result(tool_call_id, msg, error=True) + except (httpx.RequestError, httpx.TimeoutException, httpx.DecodingError, RuntimeError) as exc: + # Some httpx exceptions (notably timeouts wrapping asyncio.TimeoutError) can + # carry an empty str(), so always include the class name for diagnosability. + detail = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ + logger.error("Tool %s request failed: %s", name, detail) + return _tool_result(tool_call_id, f"Request failed: {detail}", error=True) + + async def _get_weather(self, location: str, cancel_event: asyncio.Event) -> str: + data = await self._request_with_retry("Weather", "/weather", {"location": location}, cancel_event) + return _format_weather(data) + + async def _research_topic(self, topic: str, cancel_event: asyncio.Event) -> str: + data = await self._request_with_retry("Research", "/research", {"topic": topic}, cancel_event) + if not data or "topic" not in data or not data.get("summary"): + logger.warning("Research returned empty/incomplete response: %s", data) return f"Research for '{topic}' returned no results." - research = models.ResearchResponse(**resp) - if research.cached: - logger.info("Research returned cached result (age=%ds)", research.cache_age_seconds or 0) - return research.display() - - async def _request(self, path: str, params: dict, cancel_event: asyncio.Event | None) -> dict: - """Make a GET request to the Elyos API and return parsed JSON. - - If a ``cancel_event`` is provided, the request is raced against - it for instant cancellation. + return _format_research(data) - Args: - path: API path (e.g. ``"/weather"``). - params: Query parameters to send. - cancel_event: Optional event that cancels the request when set. - - Returns: - Parsed JSON response as a dict. - - Raises: - asyncio.CancelledError: If the cancel event fires before - or during the request. - httpx.HTTPStatusError: On non-2xx status codes. - httpx.DecodingError: If the response content-type is not JSON. - """ - if cancel_event and cancel_event.is_set(): + async def _request_with_retry( + self, + label: str, + path: str, + params: dict, + cancel_event: asyncio.Event, + ) -> dict: + retry_after = 1 + for attempt in range(MAX_RETRIES): + data = await self._request(path, params, cancel_event) + if data.get("status") != "throttled": + return data + + retry_after = min(int(data.get("retry_after_seconds", 1)), MAX_THROTTLE_WAIT) + logger.warning("%s throttled (attempt %d/%d), retry in %ds", label, attempt + 1, MAX_RETRIES, retry_after) + if attempt < MAX_RETRIES - 1: + await _wait_or_cancel(retry_after, cancel_event) + raise RuntimeError(f"{label} API is rate-limited. Please try again in {retry_after}s.") + + async def _request(self, path: str, params: dict, cancel_event: asyncio.Event) -> dict: + if cancel_event.is_set(): raise asyncio.CancelledError logger.debug("API request: GET %s params=%s", path, params) - request_coro = self._client.get(path, params=params) - resp = await (self._cancellable_request(request_coro, cancel_event) if cancel_event else request_coro) - - logger.debug("API response: %d %s", resp.status_code, resp.headers.get("content-type", "")) - resp.raise_for_status() + response = await _race_with_cancel(self._client.get(path, params=params), cancel_event) + logger.debug("API response: %d %s", response.status_code, response.headers.get("content-type", "")) + response.raise_for_status() - content_type = resp.headers.get("content-type", "") + content_type = response.headers.get("content-type", "") if "json" not in content_type: raise httpx.DecodingError(f"Unexpected response format (got {content_type})") - - return resp.json() - - @staticmethod - async def _cancellable_request( - request_coro: typing.Coroutine[typing.Any, typing.Any, httpx.Response], - cancel_event: asyncio.Event, - ) -> httpx.Response: - """Race an HTTP request against a cancel event using ``asyncio.wait``. - - Whichever task completes first wins; the loser is cancelled and - awaited to avoid dangling coroutines. - - Args: - request_coro: The HTTP request coroutine to execute. - cancel_event: Event that, when set, aborts the request. - - Returns: - The HTTP response if the request finishes first. - - Raises: - asyncio.CancelledError: If the cancel event fires first. - """ - request_task = asyncio.create_task(request_coro) - cancel_task = asyncio.create_task(cancel_event.wait()) - done, pending = await asyncio.wait({request_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - if cancel_task in done: - raise asyncio.CancelledError - return request_task.result() - - async def execute_batch( - self, - tool_calls: list[tc_module.ChatCompletionMessageToolCall], - cancel_event: asyncio.Event | None = None, - ) -> list[models.ToolResult]: - """Execute multiple tool calls concurrently via ``asyncio.gather``. - - Args: - tool_calls: List of LLM-generated tool calls to execute. - cancel_event: Optional event that cancels all in-flight - requests when set. - - Returns: - List of ``ToolResult`` objects in the same order as the input. - """ - logger.info("Executing %d tool calls in parallel", len(tool_calls)) - tasks = [self.execute(tc, cancel_event) for tc in tool_calls] - return list(await asyncio.gather(*tasks)) + return response.json() + + +async def _wait_or_cancel(delay: float, cancel_event: asyncio.Event) -> None: + await _race_with_cancel(asyncio.sleep(delay), cancel_event) + + +async def _race_with_cancel(awaitable, cancel_event: asyncio.Event): + work_task = asyncio.create_task(awaitable) + cancel_task = asyncio.create_task(cancel_event.wait()) + done, pending = await asyncio.wait({work_task, cancel_task}, return_when=asyncio.FIRST_COMPLETED) + for task in pending: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + if cancel_task in done: + work_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await work_task + raise asyncio.CancelledError + return work_task.result() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c432c88..dde0c5e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -3,146 +3,121 @@ from __future__ import annotations import asyncio +import os import pytest -from cli_chat import models from cli_chat import tools as tools_module +from cli_chat.tools import _format_research, _format_weather -# ── Fixtures ────────────────────────────────────────────────────────────────── - - -@pytest.fixture -def settings() -> models.Settings: - return models.Settings() # type: ignore[call-arg] - +ELYOS_BASE_URL = os.getenv("ELYOS_BASE_URL", "https://elyos-interview-907656039105.europe-west2.run.app") @pytest.fixture -def executor(settings: models.Settings) -> tools_module.ToolExecutor: - return tools_module.ToolExecutor(settings) - - -class _FakeToolCall: - """Minimal stand-in for ChatCompletionMessageToolCall.""" - - def __init__(self, name: str, arguments: str, call_id: str = "test-id") -> None: - self.id = call_id - self.type = "function" - self.function = _FakeFunction(name, arguments) +def executor() -> tools_module.ToolExecutor: + return tools_module.ToolExecutor(ELYOS_BASE_URL, os.environ["ELYOS_API_KEY"]) -class _FakeFunction: - def __init__(self, name: str, arguments: str) -> None: - self.name = name - self.arguments = arguments +def _weather_call(location: str) -> dict: + return { + "id": "test-id", + "type": "function", + "function": {"name": "get_weather", "arguments": f'{{"location": "{location}"}}'}, + } -def _weather_call(location: str) -> _FakeToolCall: - return _FakeToolCall("get_weather", f'{{"location": "{location}"}}') +def _research_call(topic: str) -> dict: + return { + "id": "test-id", + "type": "function", + "function": {"name": "research_topic", "arguments": f'{{"topic": "{topic}"}}'}, + } -def _research_call(topic: str) -> _FakeToolCall: - return _FakeToolCall("research_topic", f'{{"topic": "{topic}"}}') - - -# ── Weather API tests ───────────────────────────────────────────────────────── - +def _no_cancel() -> asyncio.Event: + return asyncio.Event() class TestWeatherAPI: @pytest.mark.asyncio async def test_valid_city_returns_data(self, executor: tools_module.ToolExecutor) -> None: - result = await executor.execute(_weather_call("London")) # type: ignore[arg-type] - assert not result.error - assert "London" in result.content + result = await executor.execute(_weather_call("London"), _no_cancel()) + assert not result["error"] + assert "London" in result["content"] @pytest.mark.asyncio async def test_handles_array_format(self, executor: tools_module.ToolExecutor) -> None: """Weather API non-deterministically returns flat or array format.""" - result = await executor.execute(_weather_call("Tokyo")) # type: ignore[arg-type] - assert not result.error - assert "Tokyo" in result.content - # Should work regardless of which format the API returns + result = await executor.execute(_weather_call("Tokyo"), _no_cancel()) + assert not result["error"] + assert "Tokyo" in result["content"] @pytest.mark.asyncio async def test_multi_word_city(self, executor: tools_module.ToolExecutor) -> None: - result = await executor.execute(_weather_call("San Francisco")) # type: ignore[arg-type] - assert not result.error - assert "San Francisco" in result.content + result = await executor.execute(_weather_call("San Francisco"), _no_cancel()) + assert not result["error"] + assert "San Francisco" in result["content"] @pytest.mark.asyncio async def test_invalid_city_returns_error(self, executor: tools_module.ToolExecutor) -> None: - result = await executor.execute(_weather_call("FakeCity999")) # type: ignore[arg-type] - assert result.error + result = await executor.execute(_weather_call("FakeCity999"), _no_cancel()) + assert result["error"] @pytest.mark.asyncio async def test_empty_location_returns_error(self, executor: tools_module.ToolExecutor) -> None: """Empty location returns 404 or may hit rate limit — both are errors.""" - result = await executor.execute(_weather_call("")) # type: ignore[arg-type] - assert result.error - assert result.content # should have an error message + result = await executor.execute(_weather_call(""), _no_cancel()) + assert result["error"] + assert result["content"] # should have an error message @pytest.mark.asyncio async def test_format_consistency_across_calls(self, executor: tools_module.ToolExecutor) -> None: """Regardless of flat vs array format, our display is consistent.""" successful = [] for _ in range(3): - r = await executor.execute(_weather_call("Berlin")) # type: ignore[arg-type] - if not r.error: + r = await executor.execute(_weather_call("Berlin"), _no_cancel()) + if not r["error"]: successful.append(r) await asyncio.sleep(1) # avoid triggering rate limit - # At least one should succeed; all successes should be consistent assert len(successful) >= 1, "All 3 calls were rate-limited" for r in successful: - assert "Berlin" in r.content - assert "°C" in r.content - - -# ── Research API tests ──────────────────────────────────────────────────────── - + assert "Berlin" in r["content"] + assert "°C" in r["content"] class TestResearchAPI: @pytest.mark.asyncio async def test_returns_summary(self, executor: tools_module.ToolExecutor) -> None: - result = await executor.execute(_research_call("solar energy")) # type: ignore[arg-type] - assert result.content - # Either real result or rate-limited message — both are valid - assert not result.error or "rate-limited" in result.content + result = await executor.execute(_research_call("solar energy"), _no_cancel()) + assert result["content"] + assert not result["error"] or "rate-limited" in result["content"] @pytest.mark.asyncio async def test_cached_result_shows_age(self, executor: tools_module.ToolExecutor) -> None: """Some topics return stale cached results with age metadata.""" - result = await executor.execute(_research_call("climate change")) # type: ignore[arg-type] - # If cached, should mention age; if throttled, that's ok too - if not result.error: - assert "climate change" in result.content.lower() or "cached" in result.content.lower() + result = await executor.execute(_research_call("climate change"), _no_cancel()) + if not result["error"]: + assert "climate change" in result["content"].lower() or "cached" in result["content"].lower() @pytest.mark.asyncio async def test_empty_topic_handled(self, executor: tools_module.ToolExecutor) -> None: """API accepts empty topic without error — we should handle it.""" - result = await executor.execute(_research_call("")) # type: ignore[arg-type] - # Should not crash, either returns a result or throttled - assert result.content - - -# ── Cancellation tests ──────────────────────────────────────────────────────── - + result = await executor.execute(_research_call(""), _no_cancel()) + assert result["content"] class TestCancellation: @pytest.mark.asyncio async def test_pre_cancelled_weather(self, executor: tools_module.ToolExecutor) -> None: cancel = asyncio.Event() cancel.set() - result = await executor.execute(_weather_call("London"), cancel_event=cancel) # type: ignore[arg-type] - assert result.error - assert "cancelled" in result.content.lower() + result = await executor.execute(_weather_call("London"), cancel) + assert result["error"] + assert "cancelled" in result["content"].lower() @pytest.mark.asyncio async def test_pre_cancelled_research(self, executor: tools_module.ToolExecutor) -> None: cancel = asyncio.Event() cancel.set() - result = await executor.execute(_research_call("test"), cancel_event=cancel) # type: ignore[arg-type] - assert result.error - assert "cancelled" in result.content.lower() + result = await executor.execute(_research_call("test"), cancel) + assert result["error"] + assert "cancelled" in result["content"].lower() @pytest.mark.asyncio async def test_cancel_during_research_sleep(self, executor: tools_module.ToolExecutor) -> None: @@ -153,74 +128,52 @@ async def cancel_after_delay() -> None: await asyncio.sleep(0.5) cancel.set() - async def execute() -> models.ToolResult: - return await executor.execute(_research_call("test cancel"), cancel_event=cancel) # type: ignore[arg-type] + async def execute() -> dict: + return await executor.execute(_research_call("test cancel"), cancel) - # Run both concurrently — cancel fires mid-execution result, _ = await asyncio.gather(execute(), cancel_after_delay()) - assert result.content # should have some content regardless - - @pytest.mark.asyncio - async def test_batch_cancellation(self, executor: tools_module.ToolExecutor) -> None: - """Batch execute respects cancellation across all tool calls.""" - cancel = asyncio.Event() - cancel.set() - results = await executor.execute_batch( - [_weather_call("London"), _research_call("test")], # type: ignore[arg-type] - cancel_event=cancel, - ) - assert all(r.error for r in results) - - -# ── Error handling tests ────────────────────────────────────────────────────── - + assert result["content"] class TestErrorHandling: @pytest.mark.asyncio async def test_invalid_json_arguments(self, executor: tools_module.ToolExecutor) -> None: - tc = _FakeToolCall("get_weather", "not valid json") - result = await executor.execute(tc) # type: ignore[arg-type] - assert result.error - assert "invalid" in result.content.lower() + tc = {"id": "test-id", "type": "function", "function": {"name": "get_weather", "arguments": "not valid json"}} + result = await executor.execute(tc, _no_cancel()) + assert result["error"] + assert "invalid" in result["content"].lower() @pytest.mark.asyncio async def test_unknown_tool_name(self, executor: tools_module.ToolExecutor) -> None: - tc = _FakeToolCall("nonexistent_tool", '{"arg": "val"}') - result = await executor.execute(tc) # type: ignore[arg-type] - assert "Unknown tool" in result.content + tc = {"id": "test-id", "type": "function", "function": {"name": "nonexistent_tool", "arguments": '{"arg": "val"}'}} + result = await executor.execute(tc, _no_cancel()) + assert "Unknown tool" in result["content"] @pytest.mark.asyncio async def test_unicode_location_handled_gracefully(self, executor: tools_module.ToolExecutor) -> None: """Unicode input causes HTML 400 from Cloud Run infra — shouldn't crash.""" - result = await executor.execute(_weather_call("東京")) # type: ignore[arg-type] - assert result.error or "Tokyo" not in result.content - # Main assertion: no crash + result = await executor.execute(_weather_call("東京"), _no_cancel()) + assert result["error"] or "Tokyo" not in result["content"] @pytest.mark.asyncio async def test_special_chars_in_location(self, executor: tools_module.ToolExecutor) -> None: """Special characters shouldn't cause crashes.""" - result = await executor.execute(_weather_call("London'; DROP TABLE --")) # type: ignore[arg-type] - # Should either return an error or handle gracefully - assert result.content - - -# ── Model normalization tests ───────────────────────────────────────────────── - + result = await executor.execute(_weather_call("London'; DROP TABLE --"), _no_cancel()) + assert result["content"] -class TestModelNormalization: - def test_flat_weather_normalized(self) -> None: +class TestFormatting: + def test_flat_weather_formatted(self) -> None: data = { "location": "London", "temperature_c": 13.0, "condition": "Overcast", "humidity": 38, } - weather = models.WeatherResponse.from_api(data) - assert len(weather.conditions) == 1 - assert weather.conditions[0].temperature_c == 13.0 - assert "London" in weather.display() + output = _format_weather(data) + assert "London" in output + assert "13.0°C" in output + assert "Overcast" in output - def test_array_weather_normalized(self) -> None: + def test_array_weather_formatted(self) -> None: data = { "location": "Tokyo", "conditions": [ @@ -229,31 +182,24 @@ def test_array_weather_normalized(self) -> None: ], "note": "Multiple conditions reported", } - weather = models.WeatherResponse.from_api(data) - assert len(weather.conditions) == 2 - assert weather.note == "Multiple conditions reported" - display = weather.display() - assert "Tokyo" in display - assert "Note:" in display + output = _format_weather(data) + assert "Tokyo" in output + assert "Partly Cloudy" in output + assert "Note:" in output def test_research_cached_display(self) -> None: - resp = models.ResearchResponse( - topic="test", - summary="Test summary", - sources=["a.com"], - cached=True, - cache_age_seconds=86400 * 30, - ) - display = resp.display() - assert "cached" in display.lower() - assert "30 days" in display - - def test_throttled_response_parsed(self) -> None: data = { - "status": "throttled", - "message": "Rate limit exceeded.", - "retry_after_seconds": 5, - "data": None, + "summary": "Test summary", + "sources": ["a.com"], + "cached": True, + "cache_age_seconds": 86400 * 30, } - throttled = models.ThrottledResponse(**data) - assert throttled.retry_after_seconds == 5 + output = _format_research(data) + assert "cached" in output.lower() + assert "30 days" in output + + def test_research_basic_display(self) -> None: + data = {"summary": "Solar energy is growing.", "sources": ["source1.com", "source2.com"]} + output = _format_research(data) + assert "Solar energy" in output + assert "source1.com" in output diff --git a/uv.lock b/uv.lock index f6fb99e..577feb8 100644 --- a/uv.lock +++ b/uv.lock @@ -49,11 +49,8 @@ source = { editable = "." } dependencies = [ { name = "httpx" }, { name = "openai" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "python-dotenv" }, { name = "rich" }, - { name = "tenacity" }, ] [package.dev-dependencies] @@ -69,11 +66,8 @@ dev = [ requires-dist = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "openai", specifier = ">=1.60.0" }, - { name = "pydantic", specifier = ">=2.10.0" }, - { name = "pydantic-settings", specifier = ">=2.7.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "rich", specifier = ">=13.9.0" }, - { name = "tenacity", specifier = ">=9.0.0" }, ] [package.metadata.requires-dev] @@ -419,20 +413,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.13.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -558,15 +538,6 @@ 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 = "tenacity" -version = "9.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, -] - [[package]] name = "tomlkit" version = "0.14.0"