AI Tools Field Guide

Open edition · no CAC needed

Advanced — Build with it

Before you start

You need Python 3.9 or later and a key in an environment variable (your first API call covers both the key and the variable). Nothing to install: the helper uses only Python's standard library (urllib.request). If you prefer the requests package, the change is three lines in _post.

Run every recipe on copies of your files, on five inputs first, and read all five outputs before you run five hundred. Nothing sensitive goes to a personal account; a work account only as your organization's rules allow. A loop makes it easier, not harder, to send something you should not have, because nobody reads each request.

The helper: one function, four vendors

Save this as llm.py in the folder where you will run the recipes. The vendor and model are variables, set outside the code, so the same recipe runs on any of the four.

"""llm.py: ask(prompt) -> text, on OpenAI, Gemini, xAI or Anthropic. Standard library only."""
import json, os, time, urllib.request, urllib.error

VENDOR = os.environ.get("LLM_VENDOR", "openai")        # openai, gemini, xai or anthropic
MODEL = os.environ["LLM_MODEL"]                        # copy a current ID from the vendor's model list
PRICE_IN = float(os.environ.get("PRICE_IN", "0"))      # dollars per million input tokens, from the price page
PRICE_OUT = float(os.environ.get("PRICE_OUT", "0"))    # dollars per million output tokens
TOTALS = {"in": 0, "out": 0}

def _post(url, headers, body):
    data = json.dumps(body).encode("utf-8")
    for attempt in (1, 2):
        req = urllib.request.Request(url, data=data, method="POST",
                                     headers={"Content-Type": "application/json", **headers})
        try:
            with urllib.request.urlopen(req, timeout=300) as resp:
                return json.load(resp)
        except urllib.error.HTTPError as err:
            wait = err.headers.get("retry-after", "30")
            if err.code == 429 and attempt == 1:       # one polite retry on a rate limit, never a loop
                time.sleep(float(wait) if wait.replace(".", "", 1).isdigit() else 30)
                continue
            raise RuntimeError(f"{err.code}: {err.read().decode('utf-8', 'replace')}") from None

def ask(prompt, system="You are a careful assistant. Use only the text you are given.",
        max_tokens=1000):
    if VENDOR in ("openai", "xai"):
        url = {"openai": "https://api.openai.com/v1/responses",
               "xai": "https://api.x.ai/v1/responses"}[VENDOR]
        key = os.environ["OPENAI_API_KEY" if VENDOR == "openai" else "XAI_API_KEY"]
        r = _post(url, {"Authorization": "Bearer " + key},
                  {"model": MODEL, "max_output_tokens": max_tokens,
                   "input": [{"role": "system", "content": system},
                             {"role": "user", "content": prompt}]})
        text = "".join(part.get("text", "") for item in r["output"]
                       if item["type"] == "message" for part in item["content"])
        used = r["usage"]["input_tokens"], r["usage"]["output_tokens"]
    elif VENDOR == "gemini":
        r = _post("https://generativelanguage.googleapis.com/v1beta/interactions",
                  {"x-goog-api-key": os.environ["GEMINI_API_KEY"]},
                  {"model": MODEL, "system_instruction": system, "input": prompt,
                   "generation_config": {"max_output_tokens": max_tokens}})
        text = "".join(part.get("text", "") for step in r["steps"]
                       if step["type"] == "model_output" for part in step["content"])
        u = r["usage"]
        used = u["total_input_tokens"], u["total_output_tokens"] + u.get("total_thought_tokens", 0)
    elif VENDOR == "anthropic":
        r = _post("https://api.anthropic.com/v1/messages",
                  {"x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01"},
                  {"model": MODEL, "max_tokens": max_tokens, "system": system,
                   "messages": [{"role": "user", "content": prompt}]})
        text = "".join(b.get("text", "") for b in r["content"] if b["type"] == "text")
        used = r["usage"]["input_tokens"], r["usage"]["output_tokens"]
    else:
        raise ValueError("Set LLM_VENDOR to openai, gemini, xai or anthropic")
    TOTALS["in"] += used[0]
    TOTALS["out"] += used[1]
    return text

def report():
    cost = TOTALS["in"] / 1e6 * PRICE_IN + TOTALS["out"] / 1e6 * PRICE_OUT
    print(f"{TOTALS['in']:,} tokens in, {TOTALS['out']:,} out, about ${cost:.2f}")

Set the three variables in the terminal before you run a recipe, for example export LLM_VENDOR=anthropic and export LLM_MODEL=... with an ID from the vendor's model list (OpenAI, Gemini, xAI, Anthropic). In PowerShell it is $env:LLM_VENDOR = "anthropic". Put the two prices from the vendor's price page into PRICE_IN and PRICE_OUT, and every recipe ends by printing what it used and roughly what it cost. If a field name ever fails, print the raw reply once with print(json.dumps(r, indent=2)) and compare it with the vendor's quickstart; these APIs change.

How to estimate a run before you start

Every estimate on this page is the same sum: tokens in times the input price, plus tokens out times the output price, both per million. A token is roughly three quarters of a word, so 1,000 words is about 1,300 tokens. The recipes give token counts, not dollars, because the prices move; put today's prices into the sum. On a thinking model, add the thinking to the output side: it is billed as output even though you never see it. The run's own report() line is the real number; compare it with your estimate after the first five inputs.

Recipe 1: summarize a folder of documents

# summarize.py: one summary file per document; a rerun skips what is done
import pathlib
from llm import ask, report

src, out = pathlib.Path("inbox"), pathlib.Path("summaries")
out.mkdir(exist_ok=True)
for doc in sorted(src.glob("*.txt")) + sorted(src.glob("*.md")):
    target = out / (doc.stem + ".summary.md")
    if target.exists():
        continue
    summary = ask("Summarize this document in five bullets for a busy reader. End each bullet "
                  "with the section or paragraph it came from, in brackets.\n\n"
                  + doc.read_text(encoding="utf-8", errors="replace"), max_tokens=600)
    target.write_text(summary, encoding="utf-8")
    print("done", doc.name)
report()

Save Word and PDF files as plain text first. Cost: 40 documents of about 3,000 words is roughly 160,000 tokens in and 10,000 out. Input dominates, so a fast, cheap model is the right first try.

Recipe 2: classify the rows of a CSV

# classify.py: add a category column; anything off-list goes to a CHECK value
import csv
from llm import ask, report

LABELS = ["billing", "access", "hardware", "software", "other"]
with open("tickets.csv", newline="", encoding="utf-8-sig") as f:
    rows = list(csv.DictReader(f))
for row in rows:
    answer = ask(f"Classify this ticket as exactly one of: {', '.join(LABELS)}. "
                 "Reply with the label only.\n\n" + row["description"], max_tokens=200)
    answer = answer.strip().strip(".").lower()
    row["category"] = answer if answer in LABELS else "CHECK: " + answer[:40]
with open("tickets-classified.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=list(rows[0]))
    writer.writeheader()
    writer.writerows(rows)
report()

The check is one line: a label that is not on the list is never written as if it were. Sort the output by category and read the CHECK rows yourself. Cost: 500 rows of about 100 words each is roughly 75,000 tokens in and a few thousand out; the cheapest model on the list usually does this well.

Recipe 3: draft from a template

# drafts.py: one draft per row of facts; a draft with gaps is marked CHECK
import csv, pathlib, re
from llm import ask, report

template = pathlib.Path("template.md").read_text(encoding="utf-8")
out = pathlib.Path("drafts")
out.mkdir(exist_ok=True)
with open("facts.csv", newline="", encoding="utf-8-sig") as f:
    for row in csv.DictReader(f):
        facts = "\n".join(f"{k}: {v}" for k, v in row.items() if v)
        draft = ask("Fill in this template using only the facts listed. Keep the headings. "
                    "Where a fact is missing, write [MISSING] instead of inventing one.\n\n"
                    f"TEMPLATE:\n{template}\n\nFACTS:\n{facts}", max_tokens=1500)
        name = re.sub(r"[^\w-]+", "-", row["name"])
        flag = ".CHECK" if "[MISSING]" in draft else ""
        (out / f"{name}{flag}.md").write_text(draft, encoding="utf-8")
report()

The template holds the headings and the fixed wording; facts.csv has one row per draft and a name column. Every draft is a first draft for a person to edit and sign. Cost: 30 drafts from a one-page template is roughly 25,000 tokens in and 25,000 out. Output dominates here, so the output price is the one to compare.

Recipe 4: compare two versions

# compare.py old.txt new.txt: code finds every change, the model explains the ones that matter
import difflib, pathlib, sys
from llm import ask, report

old, new = (pathlib.Path(p).read_text(encoding="utf-8").splitlines() for p in sys.argv[1:3])
diff = "\n".join(difflib.unified_diff(old, new, "old", "new", lineterm="", n=2))
if not diff:
    sys.exit("No differences.")
print(ask("Below is a line diff of two versions of a document. Lines starting with - were "
          "removed and + were added. List each change that alters meaning (a date, an amount, "
          "a responsibility, a requirement), quoting old and new. Then list wording-only "
          "changes in one line.\n\n" + diff, max_tokens=1500))
report()

The division of labor is the point. Python's difflib finds every changed line, and a program never misses one; the model only explains what the code found. Asking a model to compare two long documents by reading both invites it to miss a changed number. Cost: you send the changed lines and a little context, not both documents, so a 20-page revision with a dozen edits is a few thousand tokens.

Recipe 5: extract, then check in code

LLM Draft Code Check LLM Repair Code Gate Human Sign
The model drafts, code checks, the model gets one chance to repair, code decides, and a person reads what failed.
# actions.py: pull every assigned action into JSON; code checks it before anyone relies on it
import json, pathlib
from llm import ask, report

PROMPT = ("List every action this document assigns. Return ONLY a JSON array of objects with "
          "keys action, owner, due, paragraph. Use \"unknown\" when the document does not say.\n\n")

def check(reply, source):
    text = reply.strip().removeprefix("```json").removeprefix("```").removesuffix("```")
    items = json.loads(text)                      # a reply that is not JSON fails here
    if not isinstance(items, list):
        raise ValueError("not a list")
    for item in items:
        for key in ("action", "owner", "due", "paragraph"):
            if not isinstance(item.get(key), str):
                raise ValueError(f"missing {key}")
        if item["paragraph"] != "unknown" and item["paragraph"] not in source:
            raise ValueError(f"paragraph {item['paragraph']!r} is not in the document")
    return items

out = pathlib.Path("actions")
out.mkdir(exist_ok=True)
for doc in sorted(pathlib.Path("inbox").glob("*.txt")):
    source = doc.read_text(encoding="utf-8", errors="replace")
    reply = ask(PROMPT + source, max_tokens=2000)
    try:
        items = check(reply, source)
    except ValueError as problem:                 # one repair attempt, then a person
        reply = ask(PROMPT + source + f"\n\nYour last answer failed a check: {problem}. "
                    "Answer again, JSON only.", max_tokens=2000)
        try:
            items = check(reply, source)
        except ValueError as problem:
            (out / (doc.stem + ".CHECK.txt")).write_text(f"{problem}\n\n{reply}", encoding="utf-8")
            continue
    (out / (doc.stem + ".json")).write_text(json.dumps(items, indent=2), encoding="utf-8")
report()

This is the reason to use the API at all. A chat window shows you an answer; a script can refuse one. The check here is mechanical: valid JSON, every field present, every paragraph reference found in the source. A reference that passes can still be attached to the wrong claim, so a person still spot-checks the passes and reads every CHECK file. All four vendors also offer a structured-output mode that forces the reply into a JSON shape; it reduces the failures, and the code check stays. Cost: as recipe 1 for the documents that pass, and double for the ones that need a repair, so budget for the whole run twice on a first attempt.

What to do with this

Pick the recipe closest to a job on your desk, run it on five inputs with the cheapest model, and write down the report() line. Then read API costs and limits before you run the whole batch.

Vendor facts checked ; re-verify before relying on them.