Teqvault.study β€” Python Series

Phase 6: AI Integration

Add intelligence to everything you've built. Call LLMs from Python, stream responses, build tool-using agents, construct RAG systems, and integrate AI as a component β€” not a chat window.

🧠 πŸ€– ⚑ πŸ”— πŸͺ„
Phase Progress
0%
LESSON 01

How LLMs Actually Work

Before calling an API, understand what you're calling. Tokens, context windows, temperature, and the concepts that determine whether your AI integration works well or fails in production.

πŸ”€ Tokens β€” The Currency of LLMs

LLMs don't process words or characters β€” they process tokens. A token is roughly 4 characters or ΒΎ of a word in English. Every API call costs tokens and is limited by a context window measured in tokens.

# How "Hello, I am learning Python!" gets tokenized:

Hello, I am learning Python!

# 7 tokens. Code tokenizes differently β€” more tokens per line.
# Rule of thumb: 1000 tokens β‰ˆ 750 words β‰ˆ 1-2 paragraphs
What you're countingApprox tokens
A short sentence ("Hello, how are you?")~5 tokens
A paragraph (150 words)~200 tokens
A full page of text~500–700 tokens
A 10-page document~5,000–7,000 tokens
A small codebase (20 files)~50,000–100,000 tokens
πŸ” Context Window β€” The Model's Working Memory

Everything the model can "see" at once

The context window is the total number of tokens a model can process in one call β€” both input and output combined. If your context window is 200,000 tokens and your prompt uses 150,000, you have 50,000 tokens left for the response.

This is the model's entire working memory. It has no memory between separate API calls unless you explicitly include previous conversation history in each new request. Every call starts fresh β€” your code is responsible for maintaining conversation state.

  • Claude claude-sonnet-4-6: 200,000 token context window
  • GPT-4o: 128,000 token context window
  • Practical implication: you can fit entire codebases, books, or long documents in a single prompt
πŸŽ›οΈ The Key Parameters You'll Actually Tune
ParameterWhat It DoesPractical Range
temperatureRandomness / creativity. 0 = deterministic, 1 = creative0 for factual/code, 0.7 for creative writing
max_tokensMaximum length of the response256 for short answers, 4096 for long outputs
systemInstructions that shape all responses β€” the model's "persona"Set once per session, not per message
top_pNucleus sampling β€” alternative to temperatureLeave at default unless you know what you're doing
stopToken sequences that stop generation earlyUseful for structured output like JSON
⚠️
Temperature = 0 is not always bestTemperature 0 makes the model deterministic β€” same input, same output every time. This is great for code generation and structured data extraction. But for tasks where some variation is acceptable (summarization, paraphrasing), a small temperature (0.3–0.5) often produces better-sounding results. Match temperature to your use case.
πŸ“¬ The Message Structure

Every LLM API uses a conversation structure: a list of messages with roles. Understanding this is the foundation of everything else in this phase.

The message format used by all major LLM APIs
# Three roles β€” you'll use all three
messages = [
    {
        "role":    "system",    # Instructions for the model's behavior
        "content": "You are a helpful Python tutor. Be concise and use examples."
    },
    {
        "role":    "user",      # What the human said
        "content": "What is a list comprehension?"
    },
    {
        "role":    "assistant", # What the model said (for multi-turn)
        "content": "A list comprehension creates a list from an iterable..."
    },
    {
        "role":    "user",
        "content": "Show me an example with filtering."
    }
    # The model responds to the last user message,
    # with full context of everything above it.
]
LESSON 02

The Anthropic SDK

Call Claude directly from Python β€” no wrappers, no abstraction layers. The raw SDK gives you full control and is what you'll use in any production system.

πŸ”‘ Setup
Terminal
pip install anthropic
.env
ANTHROPIC_API_KEY=sk-ant-api03-...
πŸ’‘
The SDK reads the key automaticallyIf ANTHROPIC_API_KEY is set in your environment, the SDK finds it without any extra code. You never need to pass the key explicitly β€” just client = anthropic.Anthropic() and it works.
πŸš€ Your First Claude Call
Python β€” basic Claude message
import anthropic
from dotenv import load_dotenv
load_dotenv()

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    system="You are a concise Python tutor. Answer in 2-3 sentences max.",
    messages=[
        {"role": "user", "content": "What is a generator in Python?"}
    ]
)

# The response object β€” know its structure
print(response.content[0].text)    # the actual text response
print(response.model)              # which model responded
print(response.stop_reason)        # "end_turn" or "max_tokens"
print(response.usage.input_tokens)  # tokens you sent
print(response.usage.output_tokens) # tokens in the response
A generator is a special function that uses yield to produce values one at a time, pausing execution between each value. Unlike a list, it doesn't compute all values upfront β€” it generates them lazily on demand, making it memory-efficient for large sequences. model: claude-sonnet-4-5 stop_reason: end_turn input_tokens: 32 output_tokens: 47
πŸ’¬ Multi-Turn Conversation

The SDK has no built-in memory. You maintain conversation history yourself by appending messages and passing the full list each time.

Python β€” stateful conversation manager
import anthropic
from dataclasses import dataclass, field

@dataclass
class Conversation:
    """Manages a stateful conversation with Claude."""
    system:   str              = "You are a helpful assistant."
    model:    str              = "claude-sonnet-4-5"
    history:  list             = field(default_factory=list)
    _client:  anthropic.Anthropic = field(
        default_factory=anthropic.Anthropic, init=False, repr=False
    )

    def chat(self, message: str) -> str:
        # Add the user message to history
        self.history.append({"role": "user", "content": message})

        response = self._client.messages.create(
            model=self.model,
            max_tokens=2048,
            system=self.system,
            messages=self.history,  # send FULL history every time
        )

        reply = response.content[0].text
        # Add Claude's reply to history for the next turn
        self.history.append({"role": "assistant", "content": reply})
        return reply

    def reset(self) -> None:
        self.history.clear()

    def token_count(self) -> int:
        # Rough estimate: 4 chars per token
        total_chars = sum(len(m["content"]) for m in self.history)
        return total_chars // 4


# Usage
conv = Conversation(system="You are a Python expert. Be concise.")
print(conv.chat("What's the difference between a list and a tuple?"))
print(conv.chat("When would I use one over the other?"))
print(conv.chat("Give me a real example from a web API."))
# All three questions share context β€” Claude remembers the conversation
πŸ“¦ Available Claude Models
claude-opus-4-5

Most capable. Best reasoning, analysis, complex tasks. Highest cost. Use when quality matters most.

claude-sonnet-4-5

Best balance of capability and speed. The everyday workhorse. Use for most production tasks.

claude-haiku-4-5

Fastest and cheapest. Great for high-volume simple tasks β€” classification, extraction, short answers.

πŸ’‘
Model selection strategyStart with Sonnet for development. If quality isn't good enough, move to Opus. If cost or speed is a concern, move to Haiku. Profile your actual use case β€” many tasks that feel like they need Opus work perfectly on Haiku at 10% of the cost.
LESSON 03

The OpenAI SDK

OpenAI's SDK is structurally similar to Anthropic's β€” same concepts, slightly different syntax. Learn both so you can switch providers or compare outputs.

πŸ”΅ Setup & First Call
Terminal
pip install openai
Python β€” OpenAI basic call
from openai  import OpenAI
from dotenv  import load_dotenv
load_dotenv()
# Reads OPENAI_API_KEY from environment automatically

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o",
    max_tokens=1024,
    messages=[
        {"role": "system",  "content": "You are a concise Python tutor."},
        {"role": "user",    "content": "What is a generator?"}
    ]
)

# Different attribute path than Anthropic
text   = response.choices[0].message.content
reason = response.choices[0].finish_reason   # "stop" or "length"
tokens = response.usage.total_tokens
πŸ”„ Provider-Agnostic Wrapper

In production, you often want to switch providers without rewriting all your calling code. A thin abstraction layer makes this trivial.

Python β€” llm.py β€” provider-agnostic interface
from dataclasses import dataclass
from enum        import Enum
from typing      import Literal

class Provider(Enum):
    ANTHROPIC = "anthropic"
    OPENAI    = "openai"

def chat(
    prompt:   str,
    system:   str  = "You are a helpful assistant.",
    provider: Provider = Provider.ANTHROPIC,
    model:    str  = None,
    max_tokens: int = 1024,
    temperature: float = 0.7,
) -> str:
    """Single function to call any LLM provider."""

    if provider == Provider.ANTHROPIC:
        import anthropic
        model = model or "claude-sonnet-4-5"
        client = anthropic.Anthropic()
        response = client.messages.create(
            model=model, max_tokens=max_tokens,
            system=system,
            messages=[{"role": "user", "content": prompt}]
        )
        return response.content[0].text

    elif provider == Provider.OPENAI:
        from openai import OpenAI
        model = model or "gpt-4o"
        client = OpenAI()
        response = client.chat.completions.create(
            model=model, max_tokens=max_tokens, temperature=temperature,
            messages=[
                {"role": "system", "content": system},
                {"role": "user",   "content": prompt}
            ]
        )
        return response.choices[0].message.content

# Use the same function regardless of provider
result = chat("Explain list comprehensions", provider=Provider.ANTHROPIC)
result = chat("Explain list comprehensions", provider=Provider.OPENAI)
# Swap the provider line to compare quality and cost
🧠 Quick Check
You make a second API call in a conversation. The model seems to "forget" the first exchange. What's the most likely cause?
LESSON 04

Streaming Responses

Waiting 10 seconds for a complete response makes your app feel broken. Streaming delivers tokens as they're generated β€” users see output immediately, just like Claude.ai itself.

⚑ Why Streaming Matters

Without streaming: your code sends a request, waits (could be 5–30 seconds for long responses), then gets everything at once. To a user, the app looks frozen.

With streaming: the first token arrives in ~200ms. The user sees text appearing immediately, reads as it generates, and the interaction feels alive and responsive β€” even if the total generation time is the same.

🌊 Streaming with Anthropic
Python β€” Anthropic streaming
import anthropic

client = anthropic.Anthropic()

# ── Method 1: stream context manager (recommended) ──
with client.messages.stream(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Write a haiku about Python."}]
) as stream:
    for text in stream.text_stream:
        print(text, end="", flush=True)  # print each token as it arrives

    # After the loop, get final usage stats
    message = stream.get_final_message()
    print(f"\n\nTokens: {message.usage.input_tokens} in, {message.usage.output_tokens} out")


# ── Method 2: collect the full streamed text ──
with client.messages.stream(
    model="claude-sonnet-4-5",
    max_tokens=2048,
    system="You are a code reviewer.",
    messages=[{"role": "user", "content": "Review this function: def add(a,b): return a+b"}]
) as stream:
    full_text = stream.get_final_text()  # waits for completion, returns string

print(full_text)
πŸš€ Streaming in a FastAPI Endpoint

Combining FastAPI's StreamingResponse with an LLM stream gives you a real-time API β€” your frontend receives tokens as they're generated, exactly like ChatGPT's interface.

Python β€” FastAPI streaming endpoint
from fastapi           import FastAPI
from fastapi.responses import StreamingResponse
from pydantic          import BaseModel
import anthropic

app    = FastAPI()
client = anthropic.Anthropic()

class ChatRequest(BaseModel):
    message: str
    system:  str = "You are a helpful assistant."

def token_generator(message: str, system: str):
    """Generator that yields tokens from Claude as they arrive."""
    with client.messages.stream(
        model="claude-sonnet-4-5",
        max_tokens=2048,
        system=system,
        messages=[{"role": "user", "content": message}]
    ) as stream:
        for text in stream.text_stream:
            yield text   # yield each token to the HTTP response

@app.post("/chat/stream")
def stream_chat(req: ChatRequest):
    return StreamingResponse(
        token_generator(req.message, req.system),
        media_type="text/plain"
    )

# The client receives a stream of text chunks:
# "A" β†’ " list" β†’ " comprehension" β†’ " is" β†’ " a" β†’ " concise" β†’ ...
# In a React frontend: fetch with ReadableStream to display token-by-token
πŸ’‘
Server-Sent Events for productionFor production streaming, use media_type="text/event-stream" with SSE format (data: {token}\n\n). This is the standard that browsers and clients like EventSource support natively. Plain text streaming works for testing but SSE is more robust.
LESSON 05

Prompt Engineering in Code

Building prompts dynamically β€” templates, few-shot examples, chain-of-thought, and structured output extraction. The difference between a toy demo and a production AI feature.

πŸ—οΈ Prompt Templates β€” Separate Prompts from Code
Python β€” prompt_templates.py
from string  import Template
from pathlib import Path

# ── Simple f-string templates ──
def summarize_prompt(text: str, max_words: int = 100) -> str:
    return f"""Summarize the following text in {max_words} words or fewer.
Focus on the key points. Use plain language.

TEXT:
{text}

SUMMARY:"""

def classify_prompt(text: str, categories: list[str]) -> str:
    cats = ", ".join(categories)
    return f"""Classify the following text into exactly one of these categories: {cats}

Respond with ONLY the category name. No explanation.

TEXT: {text}

CATEGORY:"""

# ── Few-shot template β€” examples teach the format ──
def extract_tasks_prompt(text: str) -> str:
    return f"""Extract action items from the text as a JSON array.
Each item has: "task" (string), "owner" (string or null), "due" (date string or null).

EXAMPLE INPUT:
"John needs to finish the report by Friday. Sarah will review it next week."

EXAMPLE OUTPUT:
[
  {{"task": "Finish the report", "owner": "John", "due": "Friday"}},
  {{"task": "Review the report", "owner": "Sarah", "due": "next week"}}
]

INPUT:
{text}

OUTPUT:"""

# Usage
import anthropic, json

client = anthropic.Anthropic()

def run_prompt(prompt: str, system: str = "You are a helpful assistant.",
               temperature: float = 0) -> str:
    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        temperature=temperature,
        system=system,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text

# Extract tasks from a meeting transcript
transcript = "Jude needs to push the new feature by Thursday. The team will demo it on Friday."
result = run_prompt(extract_tasks_prompt(transcript))
tasks  = json.loads(result)
print(tasks)
# [{"task": "Push the new feature", "owner": "Jude", "due": "Thursday"}, ...]
🧠 Chain-of-Thought β€” Make the Model Think Step by Step

For complex reasoning tasks, asking the model to "think step by step" dramatically improves accuracy. This is called chain-of-thought prompting.

Python β€” chain-of-thought prompting
def analyze_code_prompt(code: str) -> str:
    return f"""Analyze the following Python code for bugs and improvements.

Think through this step by step:
1. First, understand what the code is trying to do
2. Identify any bugs or errors
3. Note any performance issues
4. Suggest improvements to readability or Pythonic style
5. Give a final summary with a priority-ordered list of changes

CODE:
```python
{code}
```

Begin your analysis:"""

# Chain-of-thought works because the model "reasons aloud" before answering
# This forces it to consider each aspect rather than jumping to a conclusion
# Use it for: code review, debugging, complex decisions, data analysis


# ── Structured output β€” ask for JSON explicitly ──
def extract_structured(text: str) -> dict:
    prompt = f"""Extract information from the text below.
Respond with ONLY valid JSON. No markdown, no explanation, no backticks.

Required fields:
- "name": string
- "email": string or null
- "phone": string or null
- "company": string or null

TEXT: {text}"""

    response = run_prompt(prompt, temperature=0)

    # Strip any accidental markdown code fences
    clean = response.strip().removeprefix("```json").removeprefix("```").removesuffix("```")
    return json.loads(clean)
LESSON 06

Tool Use & Function Calling

Tool use lets the model call your Python functions. Instead of just generating text, Claude can search the web, query your database, run calculations, or interact with any API β€” and incorporate the results into its response.

πŸ”§ How Tool Use Works

The flow has three steps: you define tools β†’ the model decides to use one β†’ you run the function and send the result back.

The tool use loop
# Step 1: You define available tools as JSON schemas
tools = [{
    "name":        "get_weather",
    "description": "Get the current weather for a city.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name"},
        },
        "required": ["city"]
    }
}]

# Step 2: The model returns a tool_use block instead of text
# response.content[0].type == "tool_use"
# response.content[0].name == "get_weather"
# response.content[0].input == {"city": "Bossier City"}

# Step 3: You run the function and send the result back
# The model uses the result to form its final response
πŸ—οΈ Complete Tool Use Implementation
Python β€” full tool use agent loop
import anthropic, json, requests

client = anthropic.Anthropic()

# ── Define your actual Python functions ──
def get_weather(city: str) -> dict:
    # Open-Meteo: free, no API key needed
    geo = requests.get(
        "https://geocoding-api.open-meteo.com/v1/search",
        params={"name": city, "count": 1}, timeout=5
    ).json()
    lat = geo["results"][0]["latitude"]
    lon = geo["results"][0]["longitude"]
    weather = requests.get(
        "https://api.open-meteo.com/v1/forecast",
        params={"latitude": lat, "longitude": lon,
                "current": "temperature_2m,wind_speed_10m"}, timeout=5
    ).json()
    current = weather["current"]
    return {"city": city, "temp_c": current["temperature_2m"],
            "wind_kmh": current["wind_speed_10m"]}

def calculate(expression: str) -> dict:
    try:
        result = eval(expression, {"__builtins__": {}})
        return {"expression": expression, "result": result}
    except Exception as e:
        return {"error": str(e)}

# ── Tool registry β€” maps tool names to functions ──
TOOLS_REGISTRY = {
    "get_weather": get_weather,
    "calculate":   calculate,
}

# ── Tool schemas β€” what the model needs to know ──
TOOLS = [
    {
        "name": "get_weather",
        "description": "Get current temperature and wind speed for a city.",
        "input_schema": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"]
        }
    },
    {
        "name": "calculate",
        "description": "Evaluate a mathematical expression. Returns the numeric result.",
        "input_schema": {
            "type": "object",
            "properties": {"expression": {"type": "string"}},
            "required": ["expression"]
        }
    }
]


def run_agent(user_message: str) -> str:
    """Run the full tool use loop until the model gives a final response."""
    messages = [{"role": "user", "content": user_message}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-5",
            max_tokens=1024,
            tools=TOOLS,
            messages=messages
        )

        # If the model is done, return the text response
        if response.stop_reason == "end_turn":
            return response.content[0].text

        # The model wants to use a tool
        if response.stop_reason == "tool_use":
            # Add model's response (including tool use request) to history
            messages.append({"role": "assistant", "content": response.content})

            # Process each tool call
            tool_results = []
            for block in response.content:
                if block.type == "tool_use":
                    func    = TOOLS_REGISTRY[block.name]
                    result  = func(**block.input)
                    print(f"πŸ”§ Called {block.name}({block.input}) β†’ {result}")
                    tool_results.append({
                        "type":        "tool_result",
                        "tool_use_id": block.id,
                        "content":     json.dumps(result)
                    })

            # Send the tool results back to the model
            messages.append({"role": "user", "content": tool_results})


# Try it
answer = run_agent("What's the weather in Bossier City, and what's 15% of 87?")
print(answer)
# πŸ”§ Called get_weather({'city': 'Bossier City'}) β†’ {'city': 'Bossier City', 'temp_c': 32.1, ...}
# πŸ”§ Called calculate({'expression': '0.15 * 87'}) β†’ {'expression': '0.15 * 87', 'result': 13.05}
# "In Bossier City it's currently 32.1Β°C with wind at 14.2 km/h. 15% of 87 is 13.05."
LESSON 07

RAG β€” Retrieval Augmented Generation

RAG makes models answer questions about your own data β€” documents, databases, codebases β€” without fine-tuning. It's the most practical AI pattern for real products.

πŸ“š The RAG Problem & Solution

The problem: LLMs are trained on public data up to a cutoff date. They know nothing about your company's documents, your codebase, your users' history, or anything private.

The naive solution: dump all your documents into the context window. This works for small amounts of data but becomes expensive and slow for large datasets.

RAG: when a question arrives, search your documents for the most relevant chunks first, then send only those chunks to the model. The model answers based on retrieved context.

πŸ” The RAG Pipeline

Four stages that happen in sequence

  • Ingest: load documents, split into chunks (500–1000 tokens each), generate an embedding vector for each chunk, store vectors in a vector database.
  • Query: user asks a question β†’ generate an embedding for the question β†’ find the N most similar chunk vectors (semantic search) β†’ retrieve those chunks.
  • Augment: build a prompt that includes the retrieved chunks as context.
  • Generate: send the augmented prompt to the LLM β†’ it answers based on the provided context, not just training data.
πŸ—οΈ Simple RAG Without a Vector Database

For small datasets (<1000 documents), you don't need a full vector database. Use OpenAI or Anthropic embeddings with a simple cosine similarity search in Python.

Terminal
pip install numpy  # for cosine similarity
Python β€” simple RAG implementation
import anthropic, json, numpy as np
from pathlib import Path

client = anthropic.Anthropic()


# ── Step 1: Chunk your documents ──
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    """Split text into overlapping chunks."""
    words  = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size - overlap):
        chunk = " ".join(words[i : i + chunk_size])
        if chunk: chunks.append(chunk)
    return chunks


# ── Step 2: Generate embeddings ──
def get_embedding(text: str) -> list[float]:
    """Get embedding vector for a text string using OpenAI."""
    from openai import OpenAI
    oai = OpenAI()
    response = oai.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding


# ── Step 3: Build the index ──
def build_index(documents: list[str]) -> dict:
    """Chunk documents and compute embeddings for each chunk."""
    all_chunks   = []
    all_embeddings = []
    for doc in documents:
        chunks = chunk_text(doc)
        for chunk in chunks:
            all_chunks.append(chunk)
            all_embeddings.append(get_embedding(chunk))
            print(f"Indexed chunk {len(all_chunks)}")
    return {"chunks": all_chunks, "embeddings": np.array(all_embeddings)}


# ── Step 4: Semantic search ──
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

def search(query: str, index: dict, top_k: int = 3) -> list[str]:
    """Find the most relevant chunks for a query."""
    query_emb = np.array(get_embedding(query))
    scores = [
        cosine_similarity(query_emb, emb)
        for emb in index["embeddings"]
    ]
    top_indices = np.argsort(scores)[:-top_k-1:-1]
    return [index["chunks"][i] for i in top_indices]


# ── Step 5: RAG query ──
def rag_query(question: str, index: dict) -> str:
    """Retrieve relevant chunks and ask Claude to answer."""
    chunks   = search(question, index, top_k=3)
    context  = "\n\n---\n\n".join(chunks)

    prompt = f"""Answer the question using ONLY the provided context.
If the answer is not in the context, say "I don't have that information."

CONTEXT:
{context}

QUESTION: {question}

ANSWER:"""

    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}]
    )
    return response.content[0].text


# ── Put it all together ──
docs = [
    Path("docs/readme.md").read_text(),
    Path("docs/api_guide.md").read_text(),
]
index = build_index(docs)
answer = rag_query("How do I authenticate with the API?", index)
print(answer)
πŸ’‘
When to use a real vector databaseFor more than ~1000 documents, use a proper vector database: ChromaDB (easy, local, open source), Pinecone (managed, scalable), or pgvector (PostgreSQL extension β€” keeps everything in one database). All have Python clients that follow the same pattern as above.
LESSON 08

Cost, Rate Limits & Reliability

AI APIs fail in ways regular APIs don't β€” rate limits, timeouts, flapping responses, unexpected costs. Handle all of it like a professional before it bites you in production.

πŸ’° Understanding Costs

LLM APIs charge per token β€” both input and output. Output tokens typically cost more than input. A single poorly designed prompt can cost 100x more than a well-designed one.

Python β€” token counting and cost estimation
import anthropic

client = anthropic.Anthropic()

# ── Count tokens BEFORE sending (saves money during development) ──
response = client.messages.count_tokens(
    model="claude-sonnet-4-5",
    system="You are a helpful assistant.",
    messages=[{"role": "user", "content": "Summarize the history of Python."}]
)
print(f"Input tokens: {response.input_tokens}")

# ── Cost tracker ──
# claude-sonnet-4-5 pricing (check anthropic.com for current rates)
INPUT_COST_PER_1K  = 0.003   # $0.003 per 1K input tokens
OUTPUT_COST_PER_1K = 0.015   # $0.015 per 1K output tokens

def estimate_cost(input_tokens: int, output_tokens: int) -> float:
    return (
        (input_tokens  / 1000) * INPUT_COST_PER_1K +
        (output_tokens / 1000) * OUTPUT_COST_PER_1K
    )

# Track costs across your app
total_cost = 0.0

def tracked_message(**kwargs) -> anthropic.types.Message:
    global total_cost
    response = client.messages.create(**kwargs)
    cost = estimate_cost(
        response.usage.input_tokens,
        response.usage.output_tokens
    )
    total_cost += cost
    print(f"[cost] ${cost:.5f} | total: ${total_cost:.4f}")
    return response
πŸ›‘οΈ Robust Error Handling & Retry Logic
Python β€” production-grade API caller
import anthropic, time, logging
from functools import wraps

log = logging.getLogger(__name__)

def with_retry(max_retries: int = 3, base_delay: float = 1.0):
    """Decorator for retrying LLM calls with exponential backoff."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)

                except anthropic.RateLimitError as e:
                    # Rate limited β€” wait and retry
                    wait = base_delay * (2 ** attempt)
                    log.warning(f"Rate limited. Waiting {wait}s (attempt {attempt+1}/{max_retries})")
                    time.sleep(wait)

                except anthropic.APIStatusError as e:
                    if e.status_code >= 500:
                        # Server error β€” retry
                        wait = base_delay * (2 ** attempt)
                        log.warning(f"Server error {e.status_code}. Retry in {wait}s")
                        time.sleep(wait)
                    else:
                        # Client error (400, 401, 403) β€” don't retry
                        log.error(f"Client error {e.status_code}: {e.message}")
                        raise

                except anthropic.APIConnectionError:
                    # Network issue β€” retry
                    wait = base_delay * (2 ** attempt)
                    log.warning(f"Connection error. Retry in {wait}s")
                    time.sleep(wait)

            raise RuntimeError(f"API call failed after {max_retries} attempts")
        return wrapper
    return decorator


@with_retry(max_retries=3)
def safe_chat(message: str) -> str:
    client = anthropic.Anthropic()
    response = client.messages.create(
        model="claude-sonnet-4-5",
        max_tokens=1024,
        messages=[{"role": "user", "content": message}]
    )
    return response.content[0].text
πŸ“‹ Production AI Checklist
ConcernSolution
Rate limitsExponential backoff retry decorator
Runaway costsSet max_tokens always, count tokens before dev calls
Unexpected outputsValidate/parse response before using it, handle JSON parse failures
Slow responsesStreaming for user-facing calls, async for batch processing
Context length exceededTrack conversation token count, summarize or trim history
Prompt injectionSanitize user input, never interpolate raw user text into privileged system prompts
No observabilityLog every call: model, tokens, latency, cost, truncated prompt
🧠 Quick Check
Your AI feature starts returning 429 errors under load. What is the correct first response?
PHASE PROJECT 🏁

Command Center β€” AI Layer

Add a full AI layer to the Command Center from Phase 3. Natural language task creation, smart summaries, document Q&A, and a tool-using assistant β€” all wired into the CLI and the FastAPI backend from Phase 5.

πŸ—ΊοΈ What to Build
command-center/ β”œβ”€β”€ ai/ β”‚ β”œβ”€β”€ client.py # Anthropic client setup + tracked_message() β”‚ β”œβ”€β”€ prompts.py # all prompt templates as functions β”‚ β”œβ”€β”€ tools.py # tool schemas + Python implementations β”‚ β”œβ”€β”€ agent.py # run_agent() tool use loop β”‚ └── rag.py # document index + rag_query() └── main.py # Typer CLI with new AI commands
βœ… Requirements Checklist
πŸ“‹
Your AI layer must include:
βœ… Natural language task creation β€” cc ai add "remind jude to finish the api by thursday" extracts title, due date, priority and creates the task
βœ… Daily summary β€” cc ai summary sends pending tasks to Claude and gets a prioritized plain-English summary
βœ… Smart complete β€” cc ai complete "finish the api" uses fuzzy natural language matching to find and complete the right task
βœ… Tool-using assistant β€” cc ai ask "what's the weather and do I have anything due today?" uses tool use to answer with real data
βœ… Document Q&A β€” cc ai ask-docs "how do I deploy this?" runs RAG over your project's markdown files
βœ… Streaming output β€” long responses stream to the terminal token-by-token
βœ… Cost tracking β€” every AI call logs estimated cost, running total shown at end of session
βœ… Retry logic β€” all API calls use the @with_retry decorator
βœ… Prompts in templates β€” no inline prompt strings in business logic
🎯 Extend the Challenge

After the baseline works, push further:

  • Add a cc ai weekly-review command that analyzes completion patterns and suggests what to tackle next week
  • Add a conversation mode β€” cc ai chat drops into an interactive REPL where you can have a back-and-forth with context about your tasks
  • Add a FastAPI endpoint POST /ai/chat that accepts a message and streams the response using Server-Sent Events
  • Add a RAG index over your task history β€” "what tasks did I complete last month related to Python?"
  • Add cost budget enforcement β€” if daily AI spend exceeds $0.50, stop making calls and log a warning
πŸš€
Phase 6 Complete. You now understand how LLMs work at the token level, can call Claude and GPT-4 directly, stream responses, build prompts programmatically, give models tools to call, implement RAG over your own documents, and handle all the production realities of cost, rate limits, and reliability. Two phases left: Phase 7 is Testing & Quality β€” where you make everything you've built bulletproof. Phase 8 is Deployment β€” where you ship it.
Roadmap