Module 3: Detecting Hallucinations in Code
Types of Hallucinations in Code
Types of Hallucinations in Code
Capsule overview
Not all hallucinations are the same. A fake import can be detected with pip install. An invented parameter can be verified with the documentation. But a function that claims to validate emails and only checks for @ — that requires reading the implementation with a critical eye.
In this capsule you're going to learn the complete taxonomy of hallucinations in code. There are 4 types, from lowest to highest detection difficulty: fake imports, APIs with invented signatures, incorrect parameters, and fabricated logic. For each type you're going to see subtle examples — not import unicorn_magic, but code an experienced developer might accept without question.
The Taxonomy: 4 Types of Hallucinations
Overview
Type 1: Fake Imports
├── Detection difficulty: ⬛⬜⬜⬜ (Low)
├── Dangerousness: ⬛⬛⬜⬜ (Medium)
├── Fails at: import time / pip install
└── Example: from sklearn.metrics import roc_auc_multiclass
Type 2: APIs with Invented Signatures
├── Detection difficulty: ⬛⬛⬜⬜ (Medium)
├── Dangerousness: ⬛⬛⬛⬜ (High)
├── Fails at: runtime when the function is called
└── Example: pd.DataFrame.to_json(orient="dict")
Type 3: Incorrect Parameters
├── Detection difficulty: ⬛⬛⬛⬜ (High)
├── Dangerousness: ⬛⬛⬛⬜ (High)
├── Fails at: runtime, sometimes silently
└── Example: requests.get(url, verify_ssl=True)
Type 4: Fabricated Logic
├── Detection difficulty: ⬛⬛⬛⬛ (Very High)
├── Dangerousness: ⬛⬛⬛⬛ (Very High)
├── Fails at: runtime with specific inputs
└── Example: validate_email() that only checks "@"
Notice that detection difficulty and dangerousness increase together. The hallucinations that are easiest to detect are the least dangerous (because they're detected quickly). The hardest ones are the most dangerous (because they reach production).
Type 1: Fake Imports
What they are
The LLM generates an import of a module, class, or function that doesn't exist in the referenced package. The import may refer to a package that doesn't exist at all, or — more subtly — to a submodule or function that doesn't exist within a real package.
Why they happen
The LLM has seen thousands of imports of a library and extrapolates. If from sklearn.metrics import roc_auc_score exists, the LLM can infer that roc_auc_multiclass also exists because the pattern fits. It doesn't verify — it invents based on linguistic probability.
Subtle examples
Example 1: A submodule that doesn't exist in a real package
# ❌ Hallucination: OAuth2TokenValidator doesn't exist in FastAPI
from fastapi.security import OAuth2TokenValidator
# ✅ What does exist:
from fastapi.security import OAuth2PasswordBearer
from fastapi.security import OAuth2AuthorizationCodeBearer
from fastapi.security import SecurityScopes
Why is it subtle? Because fastapi.security is a real module with real OAuth2 classes. OAuth2TokenValidator sounds like something that should exist alongside OAuth2PasswordBearer. The naming convention is consistent. But it doesn't exist.
Example 2: A function that sounds real in a real module
# ❌ Hallucination: roc_auc_multiclass doesn't exist
from sklearn.metrics import roc_auc_multiclass
# ✅ What does exist:
from sklearn.metrics import roc_auc_score
# For multiclass, use the multi_class parameter:
score = roc_auc_score(y_true, y_pred, multi_class="ovr")
Why is it subtle? Because sklearn has dozens of functions in metrics. A function called roc_auc_multiclass fits the module's style perfectly.
Example 3: A real package, an alias that doesn't exist
# ❌ Hallucination: JSONDecodeError isn't imported like this in Python
from json.exceptions import JSONDecodeError
# ✅ What does exist:
from json import JSONDecodeError
# Or simply:
import json
# And use: json.JSONDecodeError
Why is it subtle? Because JSONDecodeError is a real exception in json. The LLM reasoned that the exceptions would be in an exceptions submodule (like in many other libraries), but in Python's json library that submodule doesn't exist.
Example 4: A testing class that sounds logical
# ❌ Hallucination: AsyncTestClient doesn't exist in httpx
from httpx import AsyncTestClient
# ✅ What does exist:
from httpx import AsyncClient
# For testing with FastAPI:
from httpx import ASGITransport, AsyncClient
Why is it subtle? Because httpx has AsyncClient and it's used a lot for testing. AsyncTestClient sounds like a specialized version for tests — but it doesn't exist.
Warning signs for imports
- ✅ Import from a Python standard module (
os,json,datetime) → Almost certainly correct - ✅ Import of a framework's main class (
from fastapi import FastAPI) → Almost certainly correct - ⚠️ Import of a specific submodule (
from fastapi.security import ...) → Verify - ⚠️ Import with a compound name that "sounds real" → Verify
- ❌ Import of something you've never seen in the documentation → Always verify
How to verify quickly
# Method 1: Try to import it
python -c "from fastapi.security import OAuth2TokenValidator"
# ImportError → hallucination confirmed
# Method 2: List the module's contents
python -c "import fastapi.security; print(dir(fastapi.security))"
# Method 3: Search on PyPI
pip show fastapi | grep -i "location"
# Then navigate to the source code
Type 2: APIs with Invented Signatures
What they are
The LLM calls a function or method that does exist, but with a signature (combination of arguments) that doesn't correspond to the real API. The function exists, the arguments it uses don't.
Why they happen
The LLM has seen the same function used with different arguments in different versions, in different wrappers, and in different contexts. It mixes patterns and generates a combination that never existed in any version.
Subtle examples
Example 1: A method with an invented argument value
import pandas as pd
df = pd.DataFrame({"name": ["Alice", "Bob"], "age": [30, 25]})
# ❌ Hallucination: "dict" is not a valid value of orient
result = df.to_json(orient="dict")
# ✅ Valid values of orient:
# "split", "records", "index", "columns", "values", "table"
result = df.to_json(orient="records")
Why is it subtle? Because orient is a real parameter of to_json(), and "dict" sounds like a logical serialization format (in fact, df.to_dict() exists as a separate method). The LLM mixed to_json(orient=...) with to_dict().
Example 2: A function with an argument from another version
import jwt
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
secret = "my-secret-key"
# ❌ Hallucination: verify=True doesn't exist as a parameter
decoded = jwt.decode(token, secret, algorithms=["HS256"], verify=True)
# ✅ The correct thing in modern PyJWT:
decoded = jwt.decode(
token,
secret,
algorithms=["HS256"],
options={"verify_signature": True}
)
Why is it subtle? Because verify sounds like a logical parameter for a JWT decode function. And in old versions of PyJWT or in other JWT libraries, a similar parameter existed. The LLM mixed the current API with the legacy one.
Example 3: An ORM method with an incorrect argument
from sqlalchemy import select
from sqlalchemy.orm import Session
# ❌ Hallucination: eager_load isn't a parameter of select()
stmt = select(User).where(User.active == True).eager_load(User.posts)
# ✅ The correct thing in SQLAlchemy:
from sqlalchemy.orm import joinedload
stmt = select(User).where(User.active == True).options(joinedload(User.posts))
Why is it subtle? Because eager_load is a real ORM concept (eager loading vs lazy loading). The LLM used the concept's name as if it were a method, when the real method is options(joinedload(...)).
Example 4: A constructor with a parameter that doesn't exist
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
# ❌ Hallucination: unique=True isn't a parameter of Field()
email: str = Field(..., unique=True, description="User email")
name: str = Field(..., min_length=2, max_length=100)
# ✅ Field() accepts: default, alias, title, description,
# gt, ge, lt, le, min_length, max_length, pattern, etc.
# "unique" is a database concept, not a Pydantic validation
Why is it subtle? Because unique=True is a real constraint — in SQLAlchemy, in Django ORM, in many ORMs. But Pydantic validates data, it doesn't define database schemas. The LLM mixed the domains.
Warning signs for invented APIs
- ✅ Standard use of a function with documented arguments → Probably correct
- ⚠️ A real function with an argument you haven't seen before → Verify
- ⚠️ A chained method that "sounds like" a concept → Verify (.eager_load, .with_cache, etc.)
- ❌ A parameter that crosses domains (a DB concept in validation, an ORM concept in an API) → Almost certainly a hallucination
How to verify quickly
# Method 1: Inspect the function's signature
import inspect
import pandas as pd
print(inspect.signature(pd.DataFrame.to_json))
# Shows all the real parameters
# Method 2: Use help()
help(pd.DataFrame.to_json)
# Shows documentation with parameters
# Method 3: Verify in the official documentation
# https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.to_json.html
Type 3: Incorrect Parameters
What they are
The LLM uses a real function with a parameter that sounds correct but isn't the real name of the parameter. Unlike Type 2, here the error is in the name of the parameter, not in its value or in the existence of the function.
Why they happen
LLMs see naming patterns in APIs. If one library uses timeout and another uses request_timeout, the LLM can mix the names. If a concept is called "verify" in one library and "validate" in another, the LLM can cross the names.
Subtle examples
Example 1: A descriptive name that isn't the real one
import requests
# ❌ Hallucination: the parameter is "verify", not "verify_ssl"
response = requests.get(
"https://api.example.com/data",
verify_ssl=True,
timeout=30
)
# ✅ The correct thing:
response = requests.get(
"https://api.example.com/data",
verify=True, # controls SSL verification
timeout=30
)
Why is it subtle? verify_ssl is a more descriptive name than verify. In many other libraries and configurations, the parameter is called exactly verify_ssl or ssl_verify. The LLM used the name that makes the most semantic "sense," not the one the library uses.
Example 2: A parameter with a similar but different name
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# ❌ Hallucination: the parameter is "allow_origins", not "allowed_origins"
app.add_middleware(
CORSMiddleware,
allowed_origins=["http://localhost:3000"],
allowed_methods=["*"],
allowed_headers=["*"],
)
# ✅ The correct thing:
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["*"],
allow_headers=["*"],
)
Why is it subtle? The difference is allow_ vs allowed_. Semantically they're almost identical. And allowed_origins is grammatically more natural in English. But FastAPI/Starlette uses allow_origins (without the "d").
Example 3: A parameter from another library
import logging
# ❌ Hallucination: "log_level" isn't a parameter of basicConfig
logging.basicConfig(
log_level=logging.INFO,
format="%(asctime)s - %(message)s"
)
# ✅ The correct thing:
logging.basicConfig(
level=logging.INFO, # it's "level", not "log_level"
format="%(asctime)s - %(message)s"
)
Why is it subtle? log_level sounds more specific than level, and it's the name used in the configurations of many frameworks (like uvicorn, gunicorn, and Django). But logging.basicConfig() simply uses level.
Example 4: A parameter with an extra prefix
from sqlalchemy import create_engine
# ❌ Hallucination: "max_pool_size" isn't the correct name
engine = create_engine(
"postgresql://user:pass@localhost/db",
max_pool_size=20,
pool_timeout=30
)
# ✅ The correct thing:
engine = create_engine(
"postgresql://user:pass@localhost/db",
pool_size=20, # not "max_pool_size"
pool_timeout=30 # this one is correct
)
Why is it subtle? pool_timeout exists and is correct. max_pool_size follows the same naming pattern (pool_*) but the real parameter is pool_size. The "max_" prefix is a logical addition by the LLM but incorrect.
The special danger of this type
Incorrect parameters are especially dangerous because in Python, with **kwargs, an incorrect parameter can be silently ignored:
# Many functions accept **kwargs
# A parameter with an incorrect name is simply ignored
# There's no error — but there's also no effect
import requests
# This does NOT raise an error — but verify_ssl does nothing
response = requests.get(url, verify_ssl=False)
# SSL is still being verified because "verify" was never False
# In production, you think you disabled SSL verification
# but you didn't
That's why this type has High dangerousness: the code works without an error, but the parameter has no effect. Your intent isn't carried out and you get no warning.
Warning signs for incorrect parameters
- ✅ A parameter you've personally used many times → Probably correct
- ⚠️ A parameter with a "more descriptive" name than usual → Verify
- ⚠️ A parameter that exists in another similar library → Verify
- ⚠️ A parameter that sounds correct but you've never seen it in the docs → Verify
- ❌ Two parameters of the same type with different naming in the same call → Suspicious
How to verify quickly
# Method 1: Inspect the accepted parameters
import inspect
import requests
sig = inspect.signature(requests.get)
print(sig.parameters.keys())
# dict_keys(['url', 'params', 'kwargs'])
# Then see the documentation for kwargs
# Method 2: Use an IDE with autocomplete
# Modern IDEs show the available parameters
# If a parameter doesn't appear in autocomplete, be suspicious
# Method 3: Quick test
import requests
try:
requests.get("https://httpbin.org/get", verify_ssl=True)
print("No error — but does it work?")
except TypeError as e:
print(f"Error: {e}")
Type 4: Fabricated Logic
What they are
The LLM generates a function with a descriptive name and a correct docstring, but the implementation doesn't do what it says. Unlike a bug (where the developer tried to implement something and got it wrong), here the LLM generated an implementation that was never correct — it fabricated logic that sounds plausible but doesn't correspond to the declared purpose.
Why they happen
LLMs are excellent at generating code that looks structurally correct: good function names, coherent docstrings, correct types. But when the logic requires specific domain knowledge — mathematical formulas, validation algorithms, business rules — the LLM can generate an implementation that "seems reasonable" without being so.
Subtle examples
Example 1: A superficial validation that looks complete
import re
def validate_email(email: str) -> bool:
"""
Validates that the email address is properly formatted
according to RFC 5322 standards.
"""
if not email or not isinstance(email, str):
return False
return "@" in email and "." in email.split("@")[-1]
Why is it subtle? The function has:
- ✅ A descriptive name
- ✅ A docstring that mentions RFC 5322
- ✅ Type hints
- ✅ Input validation (not empty, is string)
- ✅ Checks for
@and.in the domain - ❌ But it accepts:
"a@b.c","@domain.com","user@.com","us er@domain.com"
Real email validation is significantly more complex. The LLM generated something that passes 80% of cases — but fails on edge cases that matter.
Example 2: An incorrect statistical calculation
from typing import List
def calculate_percentile(data: List[float], percentile: int) -> float:
"""
Calculates the given percentile of a dataset.
Uses the standard interpolation method.
Args:
data: List of numerical values
percentile: Percentile to calculate (0-100)
Returns:
The percentile value
"""
if not data:
raise ValueError("Data cannot be empty")
if not 0 <= percentile <= 100:
raise ValueError("Percentile must be between 0 and 100")
sorted_data = sorted(data)
index = (percentile / 100) * len(sorted_data)
if index == int(index):
return sorted_data[int(index)]
lower = sorted_data[int(index)]
upper = sorted_data[int(index) + 1]
return (lower + upper) / 2
Why is it subtle? The function:
- ✅ Has correct input validation
- ✅ Sorts the data
- ✅ Calculates an index based on the percentile
- ❌ The index calculation is incorrect (doesn't use standard interpolation)
- ❌ Fails with
index = len(data)(IndexError) - ❌ The interpolation doesn't correspond to any standard method (np.percentile uses well-defined methods)
The LLM generated something that produces reasonable numbers for most inputs — but doesn't implement the correct percentile calculation.
Example 3: Sanitization that doesn't sanitize
import re
def sanitize_sql_input(user_input: str) -> str:
"""
Sanitizes user input to prevent SQL injection attacks.
Removes dangerous SQL keywords and characters.
"""
dangerous_keywords = [
"DROP", "DELETE", "INSERT", "UPDATE",
"SELECT", "UNION", "ALTER", "CREATE"
]
sanitized = user_input
for keyword in dangerous_keywords:
sanitized = re.sub(
keyword, "", sanitized, flags=re.IGNORECASE
)
sanitized = sanitized.replace("'", "''")
sanitized = sanitized.replace(";", "")
return sanitized
Why is it subtle? The function:
- ✅ Has a correct name and an appropriate docstring
- ✅ Lists dangerous SQL keywords
- ✅ Removes the keywords
- ✅ Escapes single quotes
- ✅ Removes semicolons
- ❌ But blacklist-based sanitization is fundamentally incorrect for preventing SQL injection
- ❌ Easy bypass:
"SELSELECTECT"→ after removingSELECT,SELECTremains - ❌ Doesn't protect against injection with comments:
/**/ - ❌ The real solution is to use parameterized queries, not to sanitize input
The function gives a false sense of security. A developer who uses it thinks they're protected against SQL injection, but they aren't.
Example 4: A hash that isn't secure
import hashlib
def hash_password(password: str) -> str:
"""
Securely hashes a password for storage.
Uses SHA-256 hashing algorithm.
"""
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(password: str, hashed: str) -> bool:
"""Verifies a password against its hash."""
return hash_password(password) == hashed
Why is it subtle? The function:
- ✅ Uses hashlib (standard library, not invented)
- ✅ SHA-256 is a real and respected algorithm
- ✅ The verification is logically correct
- ❌ SHA-256 without a salt is vulnerable to rainbow table attacks
- ❌ SHA-256 is too fast for passwords (allows brute force)
- ❌ Doesn't use bcrypt, scrypt, or argon2 (algorithms designed for passwords)
- ❌ No salt, no iterations, no key stretching
A developer who doesn't know the difference between generic hashing and password hashing would accept this without question.
Warning signs for fabricated logic
- ✅ An implementation of something you've done before and recognize → Probably correct
- ⚠️ A function with a docstring that mentions a standard (RFC, algorithm) → Verify that the implementation matches
- ⚠️ A security function implemented from scratch → Almost always better to use a library
- ⚠️ A math/statistical function → Verify formulas against a reference
- ❌ A function that "sanitizes" or "validates" with blacklist logic → Suspicious
- ❌ A hashing/encryption function implemented manually → Always use a specialized library
How to verify
# Method 1: Quick test with edge cases
def test_validate_email():
assert validate_email("user@domain.com") == True
assert validate_email("@domain.com") == False # No local part
assert validate_email("user@.com") == False # No domain
assert validate_email("us er@domain.com") == False # Space
assert validate_email("a@b.c") == False # TLD too short
# If any fails, the validation is insufficient
# Method 2: Compare with a known library
# For email validation:
from email_validator import validate_email as real_validate
# For password hashing:
from passlib.hash import bcrypt
# For SQL: use parameterized queries, never sanitize
Dangerousness Map
When to worry more?
Easy to detect Hard to detect
──────────────────────────────────────────
Low impact │ Fake import from │ Silently ignored │
│ a standard library │ parameter │
│ (immediate crash) │ (no error) │
├────────────────────┼─────────────────────┤
High impact │ Fake import in │ Security logic │
│ critical code │ that "looks" right │
│ (crash on deploy) │ (reaches prod) │
──────────────────────────────────────────
Most dangerous quadrant:
Hard to detect + High impact = Fabricated logic in security
Verification priority
When you review AI-generated code, the priority order should be:
- Security logic (authentication, authorization, encryption, sanitization)
- Business logic (financial calculations, domain validations)
- API parameters (especially the ones that can be silently ignored)
- Function signatures (verify against documentation)
- Imports (verify that they exist)
Don't review imports first because they're the easiest to detect. Review security logic first because it's the most dangerous to overlook.
Connection to the Project
Application to the capstone project
In the capstone project in module 8, the codebase contains hallucinations of all 4 types:
| Type | Quantity in the project | Difficulty |
|---|---|---|
| Fake imports | 1-2 | Low (you find them with a linter/import) |
| Invented APIs | 1 | Medium (you need to verify docs) |
| Incorrect parameters | 1-2 | High (they can be silently ignored) |
| Fabricated logic | 1 | Very high (you need to read and understand the implementation) |
Your ability to quickly classify the type of hallucination helps you choose the correct verification technique: a linter for imports, docs for APIs, tests for logic.
From diagnosis to action
This capsule gave you the taxonomy (which types exist). Capsules 03 and 04 give you the specific detection techniques for each type. Capsule 05 gives you the automated tools. And capsule 06 is where you demonstrate that you can detect them.
Troubleshooting
Problem 1: "I don't see the difference between a bug and a fabricated-logic hallucination"
Cause: The line is subtle. A bug is a human programmer's error implementing known logic. A hallucination is logic the LLM invented without verifying.
Solution: The key question is: does the code try to implement something real or did it invent its own version? If calculate_percentile() uses numpy.percentile internally but with a bug, it's a bug. If it implements an algorithm the LLM invented, it's a hallucination. In practice, the solution is the same: verify against a reference and tests.
Problem 2: "How do I know if a parameter is real or invented without opening the docs?"
Cause: You don't have to memorize every parameter of every library. That's impossible.
Solution: Your verification instinct should activate for parameters you haven't personally used before. If it's the first time you see a parameter, verify it. If you've used it 100 times, trust it. The simple rule: "if I haven't used it before, I verify it."
Problem 3: "Types 3 and 4 seem too hard to detect"
Cause: They are — that's why capsule 05 gives you automated tools.
Solution: Your human eye isn't going to detect 100% of hallucinations. No one can. That's why you combine manual verification (your trained eye) with automated verification (linters, type checkers, tests). Capsules 03 and 04 train your eye. Capsule 05 gives you the tools that cover where your eye fails.
Problem 4: "Is it possible for a hallucination not to cause problems?"
Cause: Yes, it's possible. An invented parameter that is silently ignored may not cause visible problems.
Solution: Not causing visible problems doesn't mean it's not a problem. A verify_ssl=False that is silently ignored means SSL verification is still active — which is good, accidentally. But if your intent was to disable SSL (maybe for local testing), your code doesn't do what you think it does. The hallucination doesn't cause a crash, but it causes confusion and problematic future maintenance.
Exercises
Exercise 1: Classify hallucinations (Easy)
Classify each of these errors as Type 1 (Import), Type 2 (API), Type 3 (Parameter), Type 4 (Logic), or "Not a hallucination":
from collections import OrderedDefaultDicthashlib.sha256(data).hexdigest()when bcrypt is needed for passwordsos.path.exists()returns True but the file has no read permissionspd.read_csv("data.csv", delimiter=",")json.loads(data, encoding="utf-8")
See solution
- Type 1 (Fake import):
OrderedDefaultDictdoesn't exist incollections.OrderedDictanddefaultdictexist separately. The LLM combined both. - Type 4 (Fabricated logic):
hashlib.sha256works correctly — but using it for passwords is an incorrect security decision. The function exists and works; the logic of using it for passwords is the hallucination. - Not a hallucination: This is real behavior of
os.path.exists(). It returns True if the path exists, regardless of permissions. It's a known limitation, not a hallucination. - Not a hallucination:
delimiteris a real parameter ofpd.read_csv(). It's correct. - Type 3 (Incorrect parameter): In Python 3.9+,
json.loads()no longer accepts theencodingparameter (it was removed). In earlier versions it was deprecated. The LLM generated code from an earlier version.
Exercise 2: Identify the hallucination (Medium)
In each pair, one is correct and the other is a hallucination. Which is which?
Pair A:
# Option 1
from typing import Annotated
from fastapi import Depends, Query
# Option 2
from typing import Annotated
from fastapi import Depends, QueryParam
Pair B:
# Option 1
response = requests.post(url, json=data, timeout=30)
# Option 2
response = requests.post(url, json_data=data, timeout=30)
Pair C:
# Option 1
from pathlib import Path
content = Path("file.txt").read_text(encoding="utf-8")
# Option 2
from pathlib import Path
content = Path("file.txt").read_contents(encoding="utf-8")
See solution
Pair A: Option 1 is correct. Query is the real function/class in FastAPI. QueryParam doesn't exist — the LLM generated a name that sounds more descriptive but isn't the real one. Type 1 (Fake import).
Pair B: Option 1 is correct. json=data is the correct parameter of requests.post(). json_data doesn't exist — the LLM used a more descriptive but incorrect name. Type 3 (Incorrect parameter).
Pair C: Option 1 is correct. read_text() is the real method of pathlib.Path. read_contents() sounds similar but doesn't exist. Type 2 (Invented API).
Exercise 3: Find the subtle hallucination (Medium-Hard)
This code has exactly 1 hallucination. Find it:
from fastapi import FastAPI, HTTPException, Depends
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, EmailStr
from typing import Optional
import jwt
from datetime import datetime, timedelta
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
SECRET_KEY = "your-secret-key"
ALGORITHM = "HS256"
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: Optional[str] = None
def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(
token, SECRET_KEY,
algorithms=[ALGORITHM],
verify=True
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
See solution
The hallucination is in jwt.decode():
payload = jwt.decode(
token, SECRET_KEY,
algorithms=[ALGORITHM],
verify=True # ← HALLUCINATION: this parameter doesn't exist
)
In modern PyJWT, verify=True isn't a valid parameter of jwt.decode(). The correct thing is:
payload = jwt.decode(
token, SECRET_KEY,
algorithms=[ALGORITHM],
options={"verify_signature": True}
)
It's a Type 3 (Incorrect parameter). The subtle part: all the rest of the code is correct — OAuth2PasswordBearer, jwt.encode, jwt.ExpiredSignatureError, jwt.InvalidTokenError — everything exists and works. Only the verify=True parameter is invented.
Note: depending on the PyJWT version, verify=True might not cause an error (it would be ignored as **kwargs), but it would have no effect. Signature verification in modern PyJWT is enabled by default, so in this case the hallucination doesn't cause a security problem — but the parameter doesn't do what the developer thinks.
Exercise 4: Detect fabricated logic (Hard)
This function claims to calculate the Levenshtein distance between two strings. Is the implementation correct?
def levenshtein_distance(s1: str, s2: str) -> int:
"""
Calculates the Levenshtein (edit) distance between two strings.
Returns the minimum number of single-character edits
(insertions, deletions, substitutions) required to change
one string into the other.
"""
if len(s1) < len(s2):
return levenshtein_distance(s2, s1)
if len(s2) == 0:
return len(s1)
previous_row = range(len(s2) + 1)
for i, c1 in enumerate(s1):
current_row = [i + 1]
for j, c2 in enumerate(s2):
insertions = previous_row[j + 1] + 1
deletions = current_row[j] + 1
substitutions = previous_row[j] + (c1 != c2)
current_row.append(min(insertions, deletions, substitutions))
previous_row = current_row
return previous_row[-1]
See solution
This implementation is correct. It's not a hallucination.
It's the standard iterative implementation of the Levenshtein distance using dynamic programming with space optimization (it only keeps the previous row in memory instead of the full matrix).
How to verify:
assert levenshtein_distance("kitten", "sitting") == 3
assert levenshtein_distance("", "hello") == 5
assert levenshtein_distance("same", "same") == 0
assert levenshtein_distance("a", "b") == 1
The point of this exercise: not everything AI generates is incorrect. Part of being a good detective is knowing when the code is correct. If you treat everything as suspicious, you lose time and productivity.
Exercise 5: Create your own table (Hard)
For a library you use frequently in your work, generate a table with:
| Plausible hallucination | Why it sounds real | The correct thing | Type |
|---|
Generate at least 3 plausible hallucinations for your library. If you can't think of any, use FastAPI or requests.
See solution (example with FastAPI)
| Plausible hallucination | Why it sounds real | The correct thing | Type |
|---|---|---|---|
from fastapi import Router | Similar to Flask's Blueprint, intuitive name | from fastapi import APIRouter | Type 1 |
@app.post("/users", response_type=User) | response_type sounds logical | @app.post("/users", response_model=User) | Type 3 |
app.include_router(router, tags="users") | tags as a string sounds reasonable | app.include_router(router, tags=["users"]) — tags is a list | Type 3 |
HTTPException(status=404, message="Not found") | status and message are generic | HTTPException(status_code=404, detail="Not found") | Type 3 |
The key: all these errors are of the "sounds more intuitive than the real thing" type. LLMs tend to generate the most natural/descriptive name, not necessarily the one the library uses.
Summary
In this capsule you learned:
- Hallucinations are classified into 4 types from lowest to highest detection difficulty
- Type 1 (Fake imports): Modules, classes, or functions that don't exist → Detect with a linter/import
- Type 2 (Invented APIs): Real functions with signatures that don't exist → Detect with docs
- Type 3 (Incorrect parameters): Parameter names that sound good but aren't the real ones → Dangerous because they can be silently ignored
- Type 4 (Fabricated logic): Implementations that look correct but don't do what they say → The most dangerous, they require reading and understanding the implementation
- Dangerousness increases with detection difficulty
- Security hallucinations (Type 4) are the ones to prioritize in the review
- The dangerous hallucinations are the subtle ones — the ones that look like the correct thing
Next capsule: Hallucinations in Imports and APIs — specific techniques for detecting types 1 and 2.
Additional resources
- PyPI — Python Package Index - Verify that a Python package exists
- Python Official Documentation - Reference for Python standard modules
- FastAPI Documentation - API reference for verifying imports and parameters
- pandas API Reference - Verify pandas methods and parameters
- Real Python — Common Python Gotchas - Common errors that look like hallucinations but are real behavior
- OWASP — Input Validation Cheat Sheet - Why blacklist-based sanitization is insufficient
Debugging & Code Review with Claude Code — Module 3, Capsule 02 Claude Code Agentic Development Path — Guide #6 of 11