Build a Python terminal assistant with two explicit model providers, SQLite conversation sessions, bounded history, controlled retries, and tests.
What You Will Build
This tutorial builds a local Python chat application that lets a user choose between an OpenAI ChatGPT-style model and Anthropic Claude Sonnet 5. The application uses one internal message format, stores sessions in SQLite, keeps only a bounded number of recent messages in each request, and makes provider selection visible in the terminal.
The timing matters. Anthropic introduced Claude Sonnet 5 on June 30, 2026 as its most agentic Sonnet model. Anthropic says the model can make plans, use tools such as browsers and terminals, and run autonomously at a capability level that recently required larger and more expensive models. It also positions Sonnet 5 as close to Opus 4.8 performance at lower prices, with improvements over Sonnet 4.6 in reasoning, tool use, coding, and knowledge work.
That does not mean a local chat client should automatically give a model access to a browser, terminal, customer system, or internal database. This tutorial deliberately implements text chat only. It creates a dependable boundary for model comparison and conversational workflows first. If you later add tools, deterministic application code should validate permissions, arguments, timeouts, and approval requirements before any external action is executed.
This approach is useful for engineering teams in the GCC and Middle East that need to evaluate more than one AI provider while retaining control over their application architecture. The application does not silently send a failed Claude request to OpenAI, or the reverse. The user chooses the provider, which makes routing behaviour visible during technical evaluation and governance review.
Architecture
config.pyreads required environment variables and validates safe local limits.providers.pyconverts one internal conversation format into each provider’s request format.storage.pycreates durable SQLite sessions and retrieves chronological recent history.chat.pyprovides the terminal loop, commands, controlled retries, and provider routing.
The provider adapter is the important design decision. The rest of the program depends on a small internal contract rather than directly on a vendor SDK. That makes the application easier to test and lets you add an approved internal gateway later without rewriting persistence or command handling.
Prerequisites and Setup
- Python 3.10 or newer.
- An OpenAI API key and a model identifier available to your account.
- An Anthropic API key and access to Claude Sonnet 5.
- Basic familiarity with virtual environments, environment variables, and the terminal.
- SQLite, which is included with standard CPython installations.
mkdir multi-model-chat
cd multi-model-chat
python -m venv .venv
# macOS and Linux
source .venv/bin/activate
# Windows PowerShell
# .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install openai anthropic rich pytest
mkdir testsDo not place credentials in Python source files. Set them in your shell, CI secret store, container runtime, or approved deployment secret manager. The application requires a configured model name for each provider because model availability is account-specific.
# macOS and Linux
export OPENAI_API_KEY="your-openai-key"
export OPENAI_MODEL="your-openai-model"
export ANTHROPIC_API_KEY="your-anthropic-key"
export ANTHROPIC_MODEL="claude-sonnet-5"
# Optional local limits
export MAX_HISTORY_MESSAGES="20"
export MAX_OUTPUT_TOKENS="1200"
export REQUEST_TIMEOUT_SECONDS="60"On Windows PowerShell, use $env:OPENAI_API_KEY="..." syntax instead. In a production deployment, inject these same variable names through the platform’s managed secret mechanism. Never print keys in logs, commit them to Git, or ship them to browser code.
Step 1: Add Configuration Validation
Create config.py. This small module keeps configuration out of business logic and fails early when a limit is invalid. It does not require a dotenv dependency; environment variables are its only input.
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Settings:
openai_api_key: str | None
openai_model: str | None
anthropic_api_key: str | None
anthropic_model: str | None
max_history_messages: int
max_output_tokens: int
request_timeout_seconds: float
database_path: Path
def require_openai(self) -> tuple[str, str]:
if not self.openai_api_key or not self.openai_model:
raise RuntimeError(
"OPENAI_API_KEY and OPENAI_MODEL are required for OpenAI."
)
return self.openai_api_key, self.openai_model
def require_anthropic(self) -> tuple[str, str]:
if not self.anthropic_api_key or not self.anthropic_model:
raise RuntimeError(
"ANTHROPIC_API_KEY and ANTHROPIC_MODEL are required for Anthropic."
)
return self.anthropic_api_key, self.anthropic_model
def read_positive_int(name: str, default: int, minimum: int) -> int:
value = int(os.getenv(name, str(default)))
if value < minimum:
raise ValueError(f"{name} must be at least {minimum}.")
return value
def get_settings() -> Settings:
timeout = float(os.getenv("REQUEST_TIMEOUT_SECONDS", "60"))
if timeout <= 0:
raise ValueError("REQUEST_TIMEOUT_SECONDS must be greater than zero.")
return Settings(
openai_api_key=os.getenv("OPENAI_API_KEY"),
openai_model=os.getenv("OPENAI_MODEL"),
anthropic_api_key=os.getenv("ANTHROPIC_API_KEY"),
anthropic_model=os.getenv("ANTHROPIC_MODEL"),
max_history_messages=read_positive_int(
"MAX_HISTORY_MESSAGES", default=20, minimum=2
),
max_output_tokens=read_positive_int(
"MAX_OUTPUT_TOKENS", default=1200, minimum=1
),
request_timeout_seconds=timeout,
database_path=Path(os.getenv("SQLITE_DATABASE_PATH", "chat_history.sqlite3")),
)A message-count limit is a simple safeguard, not a token counter. Different models can tokenize the same text differently, and a short character count is not a reliable proxy for request size. Keeping the trimming rule isolated means you can replace it later with provider-aware token budgeting or summarisation.
Step 2: Create the Provider Adapter Layer
Create providers.py. OpenAI and Anthropic use different request and response shapes. The adapter converts both responses into CompletionResult, so the CLI does not need provider-specific parsing code.
from __future__ import annotationsfrom dataclasses import dataclass
from typing import Literal, Protocol, Sequencefrom anthropic import Anthropic
from openai import OpenAIfrom config import SettingsRole = Literal["user", "assistant"]
ProviderName = Literal["openai", "anthropic"]@dataclass(frozen=True)
class ChatMessage:
role: Role
content: str@dataclass(frozen=True)
class CompletionResult:
provider: ProviderName
model: str
text: str
input_tokens: int | None
output_tokens: int | Noneclass ChatProvider(Protocol):
name: ProviderNamedef complete(
self,
system_prompt: str,
messages: Sequence[ChatMessage],
max_output_tokens: int,
) -> CompletionResult:
...class OpenAIChatProvider:
name: ProviderName = "openai"def __init__(self, settings: Settings) -> None:
api_key, model = settings.require_openai()
self._model = model
self._client = OpenAI(
api_key=api_key,
timeout=settings.request_timeout_seconds,
max_retries=0,
)def complete(
self,
system_prompt: str,
messages: Sequence[ChatMessage],
max_output_tokens: int,
) -> CompletionResult:
response = self._client.chat.completions.create(
model=self._model,
messages=[
{"role": "system", "content": system_prompt},
*[{"role": message.role, "content": message.content} for message in messages],
],
max_tokens=max_output_tokens,
)
text = (response.choices[0].message.content or "").strip()
if not text:
raise RuntimeError("OpenAI returned an empty assistant response.")
usage = response.usage
return CompletionResult(
provider=self.name,
model=response.model,
text=text,
input_tokens=usage.prompt_tokens if usage else None,
output_tokens=usage.completion_tokens if usage else None,
)class AnthropicChatProvider:
name: ProviderName = "anthropic"def __init__(self, settings: Settings) -> None:
...Continue Reading
Log in for free to read the rest of this article and access exclusive AI tools.
Log in / Register