Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 38 additions & 65 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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.
16 changes: 8 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<timestamp>_<uuid>.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)

Expand Down
6 changes: 3 additions & 3 deletions DISCOVERIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
36 changes: 27 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -58,17 +79,14 @@ 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).

### Project Structure

```
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
```
5 changes: 1 addition & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading