{"slug": "python-claude-sonnet-5-and-chatgpt-assistant", "title": "Python Claude Sonnet 5 and ChatGPT Assistant", "summary": "Gate of AI published a technical tutorial on building a Python terminal assistant that supports both OpenAI ChatGPT-style models and Anthropic Claude Sonnet 5. The application uses a provider adapter pattern, SQLite for conversation sessions, bounded history, and controlled retries, with explicit user-selected provider routing. The tutorial emphasizes safe text-only chat and is aimed at engineering teams in the GCC and Middle East evaluating multiple AI providers.", "body_md": "🚀 Technical Briefing:This tutorial is part of our deep-dive series on Agentic Workflows at[Gate of AI]. For the full technical breakdown, interactive code sandbox, and the native Arabic translation, visit the[original article here].\n\n```\n  <span>Tutorial</span>\n  <span>Intermediate</span>\n  <span>48 min read</span>\n  <span>© Gate of AI 2026-08-21</span>\n\n<p>Build a Python terminal assistant with two explicit model providers, SQLite conversation sessions, bounded history, controlled retries, and tests.</p>\n\n<h2>What You Will Build</h2>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n<p>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.</p>\n\n<h3>Architecture</h3>\n<ul>\n  <li><code>config.py</code> reads required environment variables and validates safe local limits.</li>\n  <li><code>providers.py</code> converts one internal conversation format into each provider's request format.</li>\n  <li><code>storage.py</code> creates durable SQLite sessions and retrieves chronological recent history.</li>\n  <li><code>chat.py</code> provides the terminal loop, commands, controlled retries, and provider routing.</li>\n</ul>\n<p>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.</p>\n\n<h2>Prerequisites and Setup</h2>\n<ul>\n  <li>Python 3.10 or newer.</li>\n  <li>An OpenAI API key and a model identifier available to your account.</li>\n  <li>An Anthropic API key and access to Claude Sonnet 5.</li>\n  <li>Basic familiarity with virtual environments, environment variables, and the terminal.</li>\n  <li>SQLite, which is included with standard CPython installations.</li>\n</ul>\n\n<pre><code>mkdir multi-model-chat\n```\n\ncd multi-model-chat\n\npython -m venv .venv\n\nsource .venv/bin/activate\n\npython -m pip install --upgrade pip\n\npython -m pip install openai anthropic rich pytest\n\nmkdir tests\n\n```\n<p>Do 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.</p>\n\n<pre><code># macOS and Linux\n```\n\nexport OPENAI_API_KEY=\"your-openai-key\"\n\nexport OPENAI_MODEL=\"your-openai-model\"\n\nexport ANTHROPIC_API_KEY=\"your-anthropic-key\"\n\nexport ANTHROPIC_MODEL=\"claude-sonnet-5\"\n\nexport MAX_HISTORY_MESSAGES=\"20\"\n\nexport MAX_OUTPUT_TOKENS=\"1200\"\n\nexport REQUEST_TIMEOUT_SECONDS=\"60\"\n\n```\n<p>On Windows PowerShell, use <code>$env:OPENAI_API_KEY=\"...\"</code> 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.</p>\n\n<h2>Step 1: Add Configuration Validation</h2>\n<p>Create <code>config.py</code>. 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.</p>\n\n<pre><code>from __future__ import annotations\n```\n\nimport os\n\nfrom dataclasses import dataclass\n\nfrom pathlib import Path\n\n@dataclass(frozen=True)\n\nclass Settings:\n\nopenai_api_key: str | None\n\nopenai_model: str | None\n\nanthropic_api_key: str | None\n\nanthropic_model: str | None\n\nmax_history_messages: int\n\nmax_output_tokens: int\n\nrequest_timeout_seconds: float\n\ndatabase_path: Path\n\n``` python\ndef require_openai(self) -&gt; tuple[str, str]:\n    if not self.openai_api_key or not self.openai_model:\n        raise RuntimeError(\n            \"OPENAI_API_KEY and OPENAI_MODEL are required for OpenAI.\"\n        )\n    return self.openai_api_key, self.openai_model\n\ndef require_anthropic(self) -&gt; tuple[str, str]:\n    if not self.anthropic_api_key or not self.anthropic_model:\n        raise RuntimeError(\n            \"ANTHROPIC_API_KEY and ANTHROPIC_MODEL are required for Anthropic.\"\n        )\n    return self.anthropic_api_key, self.anthropic_model\n```\n\ndef read_positive_int(name: str, default: int, minimum: int) -> int:\n\nvalue = int(os.getenv(name, str(default)))\n\nif value < minimum:\n\nraise ValueError(f\"{name} must be at least {minimum}.\")\n\nreturn value\n\ndef get_settings() -> Settings:\n\ntimeout = float(os.getenv(\"REQUEST_TIMEOUT_SECONDS\", \"60\"))\n\nif timeout <= 0:\n\nraise ValueError(\"REQUEST_TIMEOUT_SECONDS must be greater than zero.\")\n\n```\nreturn Settings(\n    openai_api_key=os.getenv(\"OPENAI_API_KEY\"),\n    openai_model=os.getenv(\"OPENAI_MODEL\"),\n    anthropic_api_key=os.getenv(\"ANTHROPIC_API_KEY\"),\n    anthropic_model=os.getenv(\"ANTHROPIC_MODEL\"),\n    max_history_messages=read_positive_int(\n        \"MAX_HISTORY_MESSAGES\", default=20, minimum=2\n    ),\n    max_output_tokens=read_positive_int(\n        \"MAX_OUTPUT_TOKENS\", default=1200, minimum=1\n    ),\n    request_timeout_seconds=timeout,\n    database_path=Path(os.getenv(\"SQLITE_DATABASE_PATH\", \"chat_history.sqlite3\")),\n)</code></pre>\n\n<p>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.</p>\n\n<h2>Step 2: Create the Provider Adapter Layer</h2>\n<p>Create <code>providers.py</code>. OpenAI and Anthropic use different request and response shapes. The adapter converts both responses into <code>CompletionResult</code>, so the CLI does not need provider-specific parsing code.</p>\n\n<pre><code>from __future__ import annotations\n```\n\nfrom dataclasses import dataclass\n\nfrom typing import Literal, Protocol, Sequence\n\nfrom anthropic import Anthropic\n\nfrom openai import OpenAI\n\nfrom config import Settings\n\nRole = Literal[\"user\", \"assistant\"]\n\nProviderName = Literal[\"openai\", \"anthropic\"]\n\n@dataclass(frozen=True)\n\nclass ChatMessage:\n\nrole: Role\n\ncontent: str\n\n@dataclass(frozen=True)\n\nclass CompletionResult:\n\nprovider: ProviderName\n\nmodel: str\n\ntext: str\n\ninput_tokens: int | None\n\noutput_tokens: int | None\n\nclass ChatProvider(Protocol):\n\nname: ProviderName\n\n``` python\ndef complete(\n    self,\n    system_prompt: str,\n    messages: Sequence[ChatMessage],\n    max_output_tokens: int,\n) -&gt; CompletionResult:\n    ...\n```\n\nclass OpenAIChatProvider:\n\nname: ProviderName = \"openai\"\n\n``` python\ndef __init__(self, settings: Settings) -&gt; None:\n    api_key, model = settings.require_openai()\n    self._model = model\n    self._client = OpenAI(\n        api_key=api_key,\n        timeout=settings.request_timeout_seconds,\n        max_retries=0,\n    )\n\ndef complete(\n    self,\n    system_prompt: str,\n    messages: Sequence[ChatMessage],\n    max_output_tokens: int,\n) -&gt; CompletionResult:\n    response = self._client.chat.completions.create(\n        model=self._model,\n        messages=[\n            {\"role\": \"system\", \"content\": system_prompt},\n            *[{\"role\": message.role, \"content\": message.content} for message in messages],\n        ],\n        max_tokens=max_output_tokens,\n    )\n    text = (response.choices[0].message.content or \"\").strip()\n    if not text:\n        raise RuntimeError(\"OpenAI returned an empty assistant response.\")\n    usage = response.usage\n    return CompletionResult(\n        provider=self.name,\n        model=response.model,\n        text=text,\n        input_tokens=usage.prompt_tokens if usage else None,\n        output_tokens=usage.completion_tokens if usage else None,\n    )\n```\n\nclass AnthropicChatProvider:\n\nname: ProviderName = \"anthropic\"\n\n``` python\ndef __init__(self, settings: Settings) -&gt; None:\n    api_key, model = settings.require_anthropic()\n    self._model = model\n    self._client = Anthropic(\n        api_key=api_key,\n        timeout=settings.request_timeout_seconds,\n        max_retries=0,\n    )\n\ndef complete(\n    self,\n    system_prompt: str,\n    messages: Sequence[ChatMessage],\n    max_output_tokens: int,\n) -&gt; CompletionResult:\n    response = self._client.messages.create(\n        model=self._model,\n        system=system_prompt,\n        messages=[\n            {\"role\": message.role, \"content\": message.content}\n            for message in messages\n        ],\n        max_tokens=max_output_tokens,\n    )\n    text = \"\\n\".join(\n        block.text\n        for block in response.content\n        if getattr(block, \"type\", None) == \"text\"\n    ).strip()\n    if not text:\n        raise RuntimeError(\"Anthropic returned no text content.\")\n    usage = response.usage\n    return CompletionResult(\n        provider=self.name,\n        model=response.model,\n        text=text,\n        input_tokens=usage.input_tokens if usage else None,\n        output_tokens=usage.output_tokens if usage else None,\n    )\n```\n\ndef create_provider(name: ProviderName, settings: Settings) -> ChatProvider:\n\nif name == \"openai\":\n\nreturn OpenAIChatProvider(settings)\n\nreturn AnthropicChatProvider(settings)\n\n```\n<p>The OpenAI client uses the modern object-oriented SDK pattern: <code>from openai import OpenAI</code>, then <code>client.chat.completions.create()</code>. The application disables SDK retries so one application-level retry policy remains responsible for retry decisions.</p>\n\n<h2>Step 3: Persist Sessions in SQLite</h2>\n<p>Create <code>storage.py</code>. SQLite is suitable for this local single-user terminal tool because it provides a durable local database without a separate server. The retrieval query first selects the newest rows, then reorders that selected subset chronologically before sending it to a model.</p>\n\n<pre><code>from __future__ import annotations\n```\n\nimport sqlite3\n\nfrom pathlib import Path\n\nfrom uuid import uuid4\n\nfrom providers import ChatMessage, Role\n\nclass ChatStore:\n\ndef **init**(self, path: Path) -> None:\n\nself.connection = sqlite3.connect(path)\n\nself.connection.execute(\"PRAGMA foreign_keys = ON\")\n\nself.connection.executescript(\n\n\"\"\"\n\nCREATE TABLE IF NOT EXISTS sessions (\n\nid TEXT PRIMARY KEY\n\n);\n\nCREATE TABLE IF NOT EXISTS messages (\n\nid INTEGER PRIMARY KEY AUTOINCREMENT,\n\nsession_id TEXT NOT NULL,\n\nrole TEXT NOT NULL CHECK(role IN ('user', 'assistant')),\n\nprovider TEXT,\n\ncontent TEXT NOT NULL,\n\nFOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE\n\n);\n\nCREATE INDEX IF NOT EXISTS message_session_order\n\nON messages(session_id, id);\n\n\"\"\"\n\n)\n\nself.connection.commit()\n\n``` python\ndef create_session(self) -&gt; str:\n    session_id = str(uuid4())\n    self.connection.execute(\"INSERT INTO sessions(id) VALUES (?)\", (session_id,))\n    self.connection.commit()\n    return session_id\n\ndef exists(self, session_id: str) -&gt; bool:\n    return self.connection.execute(\n        \"SELECT 1 FROM sessions WHERE id = ?\", (session_id,)\n    ).fetchone() is not None\n\ndef add(self, session_id: str, role: Role, content: str, provider: str | None = None) -&gt; None:\n    self.connection.execute(\n        \"INSERT INTO messages(session_id, role, provider, content) VALUES (?, ?, ?, ?)\",\n        (session_id, role, provider, content),\n    )\n    self.connection.commit()\n\ndef recent(self, session_id: str, limit: int) -&gt; list[ChatMessage]:\n    rows = self.connection.execute(\n        \"\"\"\n        SELECT role, content FROM (\n            SELECT id, role, content FROM messages\n            WHERE session_id = ? ORDER BY id DESC LIMIT ?\n        ) ORDER BY id ASC\n        \"\"\",\n        (session_id, limit),\n    ).fetchall()\n    return [ChatMessage(role=row[0], content=row[1]) for row in rows]\n\ndef clear(self, session_id: str) -&gt; None:\n    self.connection.execute(\"DELETE FROM messages WHERE session_id = ?\", (session_id,))\n    self.connection.commit()\n\ndef close(self) -&gt; None:\n    self.connection.close()</code></pre>\n\n<h2>Step 4: Build the Interactive CLI</h2>\n<p>Create <code>chat.py</code>. The retry function only retries errors that look like a connection failure, timeout, or server-side error. It does not retry every exception. A missing key, invalid configuration, or rejected request needs correction rather than repeated network traffic.</p>\n\n<pre><code>from __future__ import annotations\n```\n\nimport argparse\n\nimport time\n\nfrom typing import Literal\n\nfrom rich.console import Console\n\nfrom rich.markdown import Markdown\n\nfrom config import get_settings\n\nfrom providers import ProviderName, create_provider\n\nfrom storage import ChatStore\n\nconsole = Console()\n\nSYSTEM_PROMPT = \"You are a precise and practical technical assistant. State important assumptions.\"\n\ndef retryable(error: Exception) -> bool:\n\nname = type(error).**name**.lower()\n\nstatus = getattr(error, \"status_code\", None)\n\nreturn \"timeout\" in name or \"connection\" in name or (isinstance(status, int) and status >= 500)\n\ndef complete_with_retry(provider_name: ProviderName, store: ChatStore, session_id: str) -> object:\n\nsettings = get_settings()\n\nprovider = create_provider(provider_name, settings)\n\nmessages = store.recent(session_id, settings.max_history_messages)\n\nlast_error: Exception | None = None\n\nfor attempt in range(1, 4):\n\ntry:\n\nreturn provider.complete(SYSTEM_PROMPT, messages, settings.max_output_tokens)\n\nexcept Exception as error:\n\nlast_error = error\n\nif not retryable(error) or attempt == 3:\n\nraise\n\ntime.sleep(min(2 ** (attempt - 1), 4))\n\nassert last_error is not None\n\nraise last_error\n\ndef arguments() -> argparse.Namespace:\n\nparser = argparse.ArgumentParser(description=\"Local multi-model terminal chat\")\n\nparser.add_argument(\"--provider\", choices=[\"openai\", \"anthropic\"], default=\"openai\")\n\nparser.add_argument(\"--session\")\n\nreturn parser.parse_args()\n\ndef main() -> None:\n\nargs = arguments()\n\nsettings = get_settings()\n\nstore = ChatStore(settings.database_path)\n\nprovider_name: ProviderName = args.provider\n\nsession_id = args.session or store.create_session()\n\nif args.session and not store.exists(session_id):\n\nraise SystemExit(f\"Session does not exist: {session_id}\")\n\n```\nconsole.print(f\"[green]Ready.[/green] provider={provider_name} session={session_id}\")\nconsole.print(\"Commands: /provider openai, /provider anthropic, /new, /clear, /status, /exit\")\ntry:\n    while True:\n        try:\n            prompt = console.input(\"[bold blue]You &gt; [/bold blue]\").strip()\n        except (EOFError, KeyboardInterrupt):\n            console.print(\"\\n[yellow]Goodbye.[/yellow]\")\n            break\n        if not prompt:\n            continue\n        if prompt == \"/exit\":\n            break\n        if prompt == \"/new\":\n            session_id = store.create_session()\n            console.print(f\"[green]New session:[/green] {session_id}\")\n            continue\n        if prompt == \"/clear\":\n            store.clear(session_id)\n            console.print(\"[yellow]Current history cleared.[/yellow]\")\n            continue\n        if prompt == \"/status\":\n            console.print(f\"provider={provider_name} session={session_id}\")\n            continue\n        if prompt.startswith(\"/provider \"):\n            choice = prompt.removeprefix(\"/provider \").strip().lower()\n            if choice in {\"openai\", \"anthropic\"}:\n                provider_name = choice\n                console.print(f\"[green]Provider changed to {choice}.[/green]\")\n            else:\n                console.print(\"[red]Choose openai or anthropic.[/red]\")\n            continue\n        if prompt.startswith(\"/\"):\n            console.print(\"[red]Unknown command.[/red]\")\n            continue\n\n        store.add(session_id, \"user\", prompt)\n        try:\n            result = complete_with_retry(provider_name, store, session_id)\n            store.add(session_id, \"assistant\", result.text, result.provider)\n            console.print(Markdown(result.text))\n            console.print(\n                f\"[dim]provider={result.provider} model={result.model} \"\n                f\"input_tokens={result.input_tokens} output_tokens={result.output_tokens}[/dim]\"\n            )\n        except Exception as error:\n            console.print(f\"[red]Request failed:[/red] {type(error).__name__}\")\n            console.print(\"Your user message remains in the local session. Correct configuration or try again.\")\nfinally:\n    store.close()\n```\n\nif **name** == \"**main**\":\n\nmain()\n\n```\n<p>Run the application with <code>python chat.py --provider anthropic</code> or <code>python chat.py --provider openai</code>. Use <code>/provider anthropic</code> during a session to switch explicitly. The previous retained conversation remains available because both adapters consume the same internal message structure.</p>\n<p>Saving the user message before the request means an interrupted or failed request remains visible in local history. That is useful for a local tool, but it can leave a user turn without an assistant reply. A larger deployment can add delivery states such as pending, completed, and failed.</p>\n\n<h2>Test the Storage Layer</h2>\n<p>Create <code>tests/test_storage.py</code>. These tests use a temporary SQLite database and do not require API keys or provider network calls.</p>\n\n<pre><code>from storage import ChatStore\n```\n\ndef test_recent_messages_are_chronological(tmp_path):\n\nstore = ChatStore(tmp_path / \"test.sqlite3\")\n\nsession = store.create_session()\n\nstore.add(session, \"user\", \"one\")\n\nstore.add(session, \"assistant\", \"two\", \"openai\")\n\nstore.add(session, \"user\", \"three\")\n\n```\nmessages = store.recent(session, 2)\n\nassert [(message.role, message.content) for message in messages] == [\n    (\"assistant\", \"two\"),\n    (\"user\", \"three\"),\n]\nstore.close()\n```\n\ndef test_clear_preserves_session(tmp_path):\n\nstore = ChatStore(tmp_path / \"test.sqlite3\")\n\nsession = store.create_session()\n\nstore.add(session, \"user\", \"remove\")\n\nstore.clear(session)\n\n```\nassert store.exists(session)\nassert store.recent(session, 20) == []\nstore.close()</code></pre>\n\n<pre><code>python -m pytest -q\n```\n\npython -m py_compile config.py providers.py storage.py chat.py\n\npython chat.py --provider anthropic\n\n```\n<h3>Production Boundaries Before You Add Agents</h3>\n<p>Claude Sonnet 5 is designed for more agentic work, including planning and tool use. Treat that capability as a reason to strengthen your application boundary, not weaken it. Keep model-generated suggestions separate from execution. Use allowlisted tools, typed inputs, short timeouts, identity and authorisation checks, audit records, and approval steps for consequential operations.</p>\n<p>For a multi-user web application, replace the local terminal interface with an authenticated backend, move from local SQLite to a database suited to the deployment's concurrency needs, and keep provider API keys on the server. Add structured operational logs that record event types and provider names without storing raw prompts by default. Prompts may contain confidential business information, source code, personal data, or customer material.</p>\n<p>Finally, evaluate models using representative tasks from your own organisation. Compare output quality, latency, token usage metadata when returned, and human review outcomes. An explicit evaluation set is more useful than assuming one provider is best for every workload. The adapter layer built here gives GCC teams a small, inspectable foundation for that comparison while retaining a clear path to more capable, controlled agent workflows.</p>\n```\n\n", "url": "https://wpnews.pro/news/python-claude-sonnet-5-and-chatgpt-assistant", "canonical_source": "https://dev.to/gateofai/python-claude-sonnet-5-and-chatgpt-assistant-2anj", "published_at": "2026-08-21 10:29:30+00:00", "updated_at": "2026-08-21 10:44:44.235814+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models", "ai-products"], "entities": ["Gate of AI", "OpenAI", "Anthropic", "Claude Sonnet 5", "ChatGPT", "SQLite", "Python"], "alternates": {"html": "https://wpnews.pro/news/python-claude-sonnet-5-and-chatgpt-assistant", "markdown": "https://wpnews.pro/news/python-claude-sonnet-5-and-chatgpt-assistant.md", "text": "https://wpnews.pro/news/python-claude-sonnet-5-and-chatgpt-assistant.txt", "jsonld": "https://wpnews.pro/news/python-claude-sonnet-5-and-chatgpt-assistant.jsonld"}}