#!/usr/bin/env python3 """Build the OpenAI models / pricing / rate-limits / deprecations fragments (+ table-heavy docs). Inputs (offline, immutable): sources/openai/models-api-raw.json live GET /v1/models (sanitized) — 2026-09-18 sources/openai/live-model-probes.json sanitized results of GET /v1/models/{id} + minimal POST /v1/responses probes sources/openai/pages/api/docs/models/*.md one page per documented model sources/openai/pages/api/docs/pricing.md pricing tables (standard / batch / flex / fast / tools / fine-tuning …) sources/openai/pages/api/docs/deprecations.md sources/openai/openapi/openapi-master.yaml model id enums (list items) Outputs: generated/fragments/models/openai-models.json generated/fragments/pricing/openai-pricing.json generated/fragments/rate-limits/openai-rate-limits.json generated/fragments/deprecations/openai-deprecations.json docs/models/openai-models.md, docs/openai/pricing.md, docs/openai/deprecations.md Re-runnable: python3 scripts/build_openai_models.py """ from __future__ import annotations import json import re from collections import OrderedDict, defaultdict from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DOCS_SRC = ROOT / "sources/openai/pages/api/docs" MODEL_PAGES = DOCS_SRC / "models" RETRIEVED_AT = "2026-09-18" TODAY = "2026-09-18" BASE_URL = "https://developers.openai.com/api/docs" OPENAPI_URL = "https://github.com/openai/openai-openapi (openapi-master.yaml, downloaded 2026-09-18)" # -------------------------------------------------------------------------------------- # Release dates known from the changelog (sources/openai/pages/api/docs/changelog.md). # Anything not listed falls back to the `created` timestamp of GET /v1/models (labelled). # -------------------------------------------------------------------------------------- RELEASE_DATES: dict[str, str] = { "gpt-6-astra": "2026-09-03", "gpt-5.6-sol": "2026-07-09", "gpt-5.6-terra": "2026-07-09", "gpt-5.6-luna": "2026-07-09", "gpt-5.6-cyber": "2026-08-07", "gpt-daybreak-blue-latest": "2026-08-07", "gpt-daybreak-red-latest": "2026-08-07", "gpt-image-2.5-flare": "2026-09-08", "gpt-image-2.5-sunburst": "2026-09-08", "gpt-live-1": "2026-09-10", "gpt-rosalind-research": "2026-09-08", "gpt-transcribe": "2026-07-28", "gpt-live-transcribe": "2026-07-28", "gpt-realtime-2.1": "2026-07-06", "gpt-realtime-2.1-mini": "2026-07-06", "chat-latest": "2026-05-05", "gpt-realtime-2": "2026-05-07", "gpt-realtime-translate": "2026-05-07", "gpt-realtime-whisper": "2026-05-07", "gpt-5.5": "2026-04-24", "gpt-5.5-pro": "2026-04-24", "gpt-image-2": "2026-04-21", "gpt-5.4-mini": "2026-03-17", "gpt-5.4-nano": "2026-03-17", "gpt-5.4": "2026-03-05", "gpt-5.4-pro": "2026-03-05", "gpt-5.3-chat-latest": "2026-03-03", "gpt-5.3-codex": "2026-02-24", "gpt-realtime-1.5": "2026-02-23", "gpt-audio-1.5": "2026-02-23", "gpt-5.2-codex": "2026-01-14", "gpt-image-1.5": "2025-12-16", "chatgpt-image-latest": "2025-12-16", "gpt-5.2": "2025-12-11", "gpt-5.2-chat-latest": "2025-12-11", "gpt-5.2-pro": "2025-12-11", "gpt-5.1-codex-max": "2025-12-04", "gpt-5.1": "2025-11-13", "gpt-5.1-codex": "2025-11-13", "gpt-5.1-chat-latest": "2025-11-13", "gpt-5.1-codex-mini": "2025-11-13", "gpt-5-pro": "2025-10-06", "gpt-realtime-mini": "2025-10-06", "gpt-audio-mini": "2025-10-06", "gpt-image-1-mini": "2025-10-06", "sora-2": "2025-10-06", "sora-2-pro": "2025-10-06", "gpt-5-codex": "2025-09-23", "gpt-realtime": "2025-08-28", "gpt-audio": "2025-08-28", "gpt-5": "2025-08-07", "gpt-5-mini": "2025-08-07", "gpt-5-nano": "2025-08-07", "gpt-5-chat-latest": "2025-08-07", "o3-deep-research": "2025-06-26", "o4-mini-deep-research": "2025-06-26", "o3-pro": "2025-06-10", "codex-mini-latest": "2025-05-15", "o3": "2025-04-16", "o4-mini": "2025-04-16", "gpt-4.1": "2025-04-14", "gpt-4.1-mini": "2025-04-14", "gpt-4.1-nano": "2025-04-14", "gpt-4o-transcribe": "2025-03-20", "gpt-4o-mini-transcribe": "2025-03-20", "gpt-4o-mini-tts": "2025-03-20", "o1-pro": "2025-03-19", "gpt-4o-search-preview": "2025-03-11", "gpt-4o-mini-search-preview": "2025-03-11", "computer-use-preview": "2025-03-11", "gpt-4.5-preview": "2025-02-27", "o3-mini": "2025-01-31", "o1": "2024-12-17", "gpt-4o-mini-realtime-preview": "2024-12-17", "gpt-4o-mini-audio-preview": "2024-12-17", "gpt-4o-audio-preview": "2024-10-17", "gpt-4o-realtime-preview": "2024-10-01", "omni-moderation-latest": "2024-09-26", "o1-preview": "2024-09-12", "o1-mini": "2024-09-12", "chatgpt-4o-latest": "2024-08-15", "gpt-4o-mini": "2024-07-18", "gpt-4o": "2024-05-13", "gpt-4-turbo": "2024-04-09", "gpt-3.5-turbo-0125": "2024-01-25", "text-embedding-3-small": "2024-01-25", "text-embedding-3-large": "2024-01-25", "gpt-4-turbo-preview": "2023-11-06", "dall-e-3": "2023-11-06", "tts-1": "2023-11-06", "tts-1-hd": "2023-11-06", "gpt-3.5-turbo-1106": "2023-11-06", } # Not in the downloaded changelog; public OpenAI announcement date, flagged as such in the record. RELEASE_DATES_EXTERNAL = {"gpt-oss-120b": "2025-08-05", "gpt-oss-20b": "2025-08-05"} LEGACY_IDS = { "gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-16k", "gpt-3.5-turbo-instruct", "gpt-3.5-turbo-instruct-0914", "gpt-4", "gpt-4-0613", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", "gpt-4-turbo-preview", "babbage-002", "davinci-002", "text-embedding-ada-002", "text-moderation-latest", "text-moderation-stable", "tts-1", "tts-1-hd", "tts-1-1106", "tts-1-hd-1106", "whisper-1", "chatgpt-4o-latest", "gpt-4o-2024-05-13", } # Models that require separate approval / provisioning per docs. RESTRICTED_ACCESS = { "gpt-5.6-cyber": "Daybreak program (separate approval and provisioning) — https://openai.com/daybreak/", "gpt-daybreak-blue-latest": "Daybreak program (separate approval and provisioning) — https://openai.com/daybreak/", "gpt-daybreak-red-latest": "Daybreak program (separate approval and provisioning) — https://openai.com/daybreak/", "gpt-5.5-cyber": "Daybreak program (pricing page only)", "gpt-5.4-cyber": "Daybreak program (deprecations page only; shutdown 2026-10-01)", "gpt-rosalind-research": "Trusted-access program for approved life-sciences research (billing starts 2026-10-05)", } # Capabilities documented outside the model pages (guides / changelog). COMPACTION_MODELS = {"gpt-5.4", "gpt-5.4-2026-03-05", "gpt-5.4-pro", "gpt-5.4-pro-2026-03-05", "gpt-5.4-mini", "gpt-5.4-mini-2026-03-17", "gpt-5.4-nano", "gpt-5.4-nano-2026-03-17"} PRO_MODE_MODELS = {"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} EXTENDED_CACHE_RETENTION = {"gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.2", "gpt-5.1-codex-max", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-chat-latest", "gpt-5", "gpt-5-codex", "gpt-4.1"} MONTHS = {m: i for i, m in enumerate( ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], 1)} def norm_date(s: str) -> str | None: """'Oct 1, 2026' | 'October 23, 2026' | '2026-09-24' | '2026‑03‑26' | 'June 3, 2026' -> ISO.""" if not s: return None s = s.replace("‑", "-").replace("–", "-").strip().strip("`") m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s) if m: return m.group(0) m = re.search(r"([A-Za-z]{3,9})\.? (\d{1,2})(?:st|nd|rd|th)?,? (\d{4})", s) if m and m.group(1)[:3].lower() in MONTHS: return f"{int(m.group(3)):04d}-{MONTHS[m.group(1)[:3].lower()]:02d}-{int(m.group(2)):02d}" return None def parse_money(cell: str): """'$0.40' -> (0.4, None); '$15.00 / 1M characters' -> (15.0, 'per 1M characters'); 'Free' -> (0, None); '-' -> None.""" c = cell.strip() if c in {"-", "—", ""}: return None if c.lower() == "free": return (0.0, None) m = re.search(r"\$([0-9][0-9,]*\.?[0-9]*)", c) if not m: return None price = float(m.group(1).replace(",", "")) unit = None m2 = re.search(r"/\s*([A-Za-z0-9 ]+)", c) if m2: unit = "per " + m2.group(1).strip() return (price, unit) def parse_int(cell: str): c = cell.strip().replace(",", "") m = re.match(r"^(\d+)", c) return int(m.group(1)) if m else None def md_table(lines: list[str], start: int): """Parse a markdown table beginning at lines[start] ('| a | b |'). Returns (header, rows, next_index).""" def cells(line: str) -> list[str]: # escaped pipes (`a \| b`) are cell content, not separators return [c.replace("\x00", "|").strip() for c in line.replace("\\|", "\x00").strip().strip("|").split("|")] header = cells(lines[start]) i = start + 1 if i < len(lines) and re.match(r"^\|\s*-", lines[i]): i += 1 rows = [] while i < len(lines) and lines[i].startswith("|"): rows.append(cells(lines[i])) i += 1 return header, rows, i # -------------------------------------------------------------------------------------- # Model page parser # -------------------------------------------------------------------------------------- DATED_RE = re.compile(r"-(\d{4}-\d{2}-\d{2}|\d{4})$") def is_dated(mid: str) -> bool: return bool(re.search(r"-\d{4}-\d{2}-\d{2}$", mid) or re.search(r"-(0613|0314|1106|0125|0301|0914)$", mid)) def parse_model_page(path: Path) -> dict: text = path.read_text(encoding="utf-8") lines = text.splitlines() page: dict = {"slug": path.stem, "file": str(path.relative_to(ROOT))} page["display_name"] = lines[0].lstrip("# ").strip() quotes = [l[2:].strip() for l in lines if l.startswith("> ") and "llms.txt" not in l] page["description"] = quotes[0] if quotes else "" m = re.search(r"Model ID: `([^`]+)`", text) page["id"] = m.group(1) if m else path.stem # intro = text between 'Model ID' line and '## Model details' intro = [] seen_id = False for l in lines: if l.startswith("Model ID:"): seen_id = True continue if l.startswith("## "): if seen_id: break continue if seen_id and l.strip(): intro.append(l.strip()) page["intro"] = " ".join(intro) # sections sections: dict[str, list[str]] = OrderedDict() cur = None for l in lines: if l.startswith("## "): cur = l[3:].strip() sections[cur] = [] elif cur is not None: sections[cur].append(l) det = sections.get("Model details", []) page["default_snapshot"] = None page["input_modalities"], page["output_modalities"], page["unsupported_modalities"] = [], [], [] page["context_window"] = page["max_output"] = page["max_input"] = None page["knowledge_cutoff"] = None page["reasoning"] = False for l in det: l = l.strip() if not l.startswith("- "): continue b = l[2:] if b.startswith("Default snapshot:"): page["default_snapshot"] = re.search(r"`([^`]+)`", b).group(1) elif b.startswith("Input modalities:"): page["input_modalities"] = [x.strip() for x in b.split(":", 1)[1].split(",")] elif b.startswith("Output modalities:"): page["output_modalities"] = [x.strip() for x in b.split(":", 1)[1].split(",")] elif b.startswith("Unsupported modalities:"): page["unsupported_modalities"] = [x.strip() for x in b.split(":", 1)[1].split(",")] elif b.endswith("context window"): page["context_window"] = parse_int(b) elif b.endswith("max output tokens"): page["max_output"] = parse_int(b) elif b.startswith("Maximum input tokens:"): page["max_input"] = parse_int(b.split(":", 1)[1]) elif b.endswith("knowledge cutoff"): page["knowledge_cutoff"] = norm_date(b) elif b.startswith("Reasoning token support"): page["reasoning"] = True # pricing tables (### subsection -> rows) + bullet notes pricing_tables: dict[str, list[dict]] = OrderedDict() notes = [] pl = sections.get("Pricing", []) sub = None i = 0 while i < len(pl): l = pl[i] if l.startswith("### "): sub = l[4:].strip() i += 1 continue if l.startswith("| Metric") and sub: _, rows, i = md_table(pl, i) # multi-line cells (sora) are already split; each row = [metric, price, unit] out = [] for r in rows: if len(r) >= 3: pm = parse_money(r[1]) out.append({"metric": r[0].strip(), "price": pm[0] if pm else None, "unit": r[2].strip()}) elif len(r) == 1: # continuation line of a multi-line metric cell pass pricing_tables[sub] = out continue if l.startswith("- "): notes.append(l[2:].strip()) elif l.strip() and not l.startswith("Pricing is based") and not l.startswith("|") and sub is None: notes.append(l.strip()) i += 1 page["pricing_tables"] = pricing_tables page["pricing_notes"] = notes # endpoints eps = [] el = sections.get("Endpoints", []) for idx, l in enumerate(el): if l.startswith("| Endpoint"): _, rows, _ = md_table(el, idx) for r in rows: if len(r) >= 3: eps.append({"name": r[0], "route": r[1].strip("`"), "supported": r[2].strip().lower() == "supported"}) break page["endpoints"] = eps def bullets(name): return [l.strip()[2:].strip() for l in sections.get(name, []) if l.strip().startswith("- ")] page["features"] = bullets("Supported features") page["has_features_section"] = "Supported features" in sections page["unsupported_features"] = bullets("Unsupported features") page["tools"] = bullets("Supported tools") page["has_tools_section"] = "Supported tools" in sections page["snapshots"] = [s.strip("`") for s in bullets("Snapshots")] # rate limits: ### section (+ optional '> note') -> table rl_sections: dict[str, dict] = OrderedDict() rl = sections.get("Rate limits", []) rl_notes = [] sub, note = "default", None i = 0 while i < len(rl): l = rl[i] if l.startswith("### "): sub, note = l[4:].strip(), None elif l.startswith("> "): note = l[2:].strip() elif l.startswith("| Tier"): header, rows, i = md_table(rl, i) tiers = OrderedDict() for r in rows: tier = r[0].strip() tiers[tier] = OrderedDict() for h, v in zip(header[1:], r[1:]): v = v.strip() if v == "-": tiers[tier][h] = None elif re.match(r"^[\d,]+$", v): tiers[tier][h] = int(v.replace(",", "")) else: tiers[tier][h] = v rl_sections[sub] = {"metrics": header[1:], "note": note, "tiers": tiers} continue elif l.strip() and not l.startswith("Rate limits ensure") and not l.startswith("|"): rl_notes.append(l.strip()) i += 1 page["rate_limits"] = rl_sections page["rate_limit_notes"] = rl_notes # reasoning effort values from intro eff = None m = re.search(r"[Rr]easoning\.effort` supports `([^.]+)`\.", text) or \ re.search(r"Reasoning\.effort supports: ([^.\n]+)\.", text) or \ re.search(r"supports `?([a-z`, ]+(?:and|,) `?[a-z]+`?) reasoning effort settings", text) or \ re.search(r"reasoning effort \(([a-z, ]+)\)", text) if m: raw = m.group(1) eff = [x.strip(" `") for x in re.split(r",| and ", raw) if x.strip(" `")] eff = [re.sub(r"\s*\(default\)", "", e).strip() for e in eff] page["reasoning_effort_default"] = None md = re.search(r"([a-z]+) \(default\)", raw) if md: page["reasoning_effort_default"] = md.group(1) if page["id"] == "gpt-5-pro": eff = ["high"] page["reasoning_effort_default"] = "high" page["reasoning_effort"] = eff return page # -------------------------------------------------------------------------------------- # Family assignment # -------------------------------------------------------------------------------------- def family_of(mid: str) -> str: m = mid if m.startswith("gpt-6"): return "gpt-6" if "cyber" in m or m.startswith("gpt-daybreak"): return "cyber-daybreak" if m == "gpt-rosalind-research": return "life-sciences" if m.startswith("gpt-oss"): return "open-weight" if m in ("chat-latest", "chatgpt-4o-latest") or m.endswith("-chat-latest"): return "chatgpt-latest" if "codex" in m: return "codex" if "embedding" in m or m.startswith("text-similarity") or m.startswith("text-search") or m.startswith("code-search"): return "embeddings" if "search" in m: return "search" if m == "computer-use-preview" or m.startswith("computer-use-preview-"): return "computer-use" if m.startswith("gpt-live-1"): return "live" if "transcribe" in m or m.startswith("whisper") or m in ("gpt-realtime-whisper", "gpt-realtime-translate"): return "speech-to-text" if "tts" in m: return "text-to-speech" if "realtime" in m: return "realtime" if "audio" in m: return "audio-chat" if "image" in m or m.startswith("dall-e"): return "image" if m.startswith("sora"): return "video" if "embedding" in m or m.startswith("text-similarity") or m.startswith("text-search") or m.startswith("code-search"): return "embeddings" if "moderation" in m: return "moderation" if re.match(r"^o[134]", m): return "o-series" if m.startswith("gpt-5.6"): return "gpt-5.6" if m.startswith("gpt-5.5"): return "gpt-5.5" if m.startswith("gpt-5.4"): return "gpt-5.4" if m.startswith("gpt-5.2"): return "gpt-5.2" if m.startswith("gpt-5.1"): return "gpt-5.1" if m.startswith("gpt-5"): return "gpt-5" if m.startswith("gpt-4.5"): return "gpt-4.5" if m.startswith("gpt-4.1"): return "gpt-4.1" if m.startswith("gpt-4o"): return "gpt-4o" if m.startswith("gpt-4"): return "gpt-4" if m.startswith("gpt-3.5"): return "gpt-3.5" if m in ("babbage-002", "davinci-002"): return "base-legacy" return "legacy-retired" # -------------------------------------------------------------------------------------- # Deprecations parser # -------------------------------------------------------------------------------------- NON_MODEL_HINTS = ("API", "OpenAI-Beta", "/v1/", "New fine-tuning training", "Videos API", "Assistants API") def split_ids(cell: str) -> list[str]: ids = re.findall(r"`([^`]+)`", cell) return [i.strip() for i in ids if i.strip()] def parse_deprecations() -> list[dict]: text = (DOCS_SRC / "deprecations.md").read_text(encoding="utf-8") lines = text.splitlines() entries = [] phase, section_title, section_anchor, announced = None, None, None, None section_text = [] i = 0 while i < len(lines): l = lines[i] if l.startswith("## Upcoming deprecations"): phase = "upcoming" elif l.startswith("## Past deprecations"): phase = "past" elif l.startswith("## ") and phase: phase = None if l.startswith("### ") and phase: section_title = l[4:].strip() section_anchor = re.sub(r"[^a-z0-9]+", "-", section_title.lower()).strip("-") m = re.match(r"(\d{4}-\d{2}-\d{2}):\s*(.*)", section_title) announced = m.group(1) if m else None section_text = [] elif l.startswith("#### ") and phase: section_text = [] if phase and section_title and l.startswith("|") and not l.startswith("| ---"): header, rows, nxt = md_table(lines, i) hl = [h.lower() for h in header] if announced is None and section_text: for st in section_text: m = re.search(r"On ([A-Z][a-z]+ \d{1,2}(?:st|nd|rd|th)?,? \d{4})", st) if m: announced = norm_date(m.group(1)) break if hl[0] == "date": # milestone table (features) for r in rows: entries.append({ "provider": "openai", "kind": "feature", "subject": section_title.split(":", 1)[-1].strip(), "ids": [], "announced": announced, "milestone_date": norm_date(r[0]), "shutdown_date": None, "update": r[1], "replacement": None, "phase": phase, "section": section_title, "source": f"{BASE_URL}/deprecations#{section_anchor}", "retrieved_at": RETRIEVED_AT, }) else: subj_idx = 1 repl_idx = next((k for k, h in enumerate(hl) if "replacement" in h or "substitute" in h), None) price_idx = next((k for k, h in enumerate(hl) if "price" in h), None) for r in rows: if len(r) < 2: continue subject_cell = r[subj_idx] ids = split_ids(subject_cell) plain = re.sub(r"[`*]", "", subject_cell) kind = "model" if any(h in plain for h in NON_MODEL_HINTS) or subject_cell.startswith("/") or plain.startswith("OpenAI-Beta"): kind = "endpoint_or_system" if plain.startswith("ft-") or "fine-tuned" in section_title.lower() and plain.startswith("ft-"): kind = "fine_tuned_model" if any(x.startswith("ft-") for x in ids): kind = "fine_tuned_model" if kind == "model" and not ids: ids = [plain.strip()] if plain.strip() and " " not in plain.strip() else [] entries.append({ "provider": "openai", "kind": kind, "subject": plain.strip(), "ids": ids, "primary_id": ids[0] if ids else None, "announced": announced, "shutdown_date": norm_date(r[0]), "shutdown_raw": r[0].strip(), "replacement": re.sub(r"\s+", " ", r[repl_idx]).strip() if repl_idx is not None and repl_idx < len(r) else None, "legacy_price": r[price_idx] if price_idx is not None and price_idx < len(r) else None, "phase": phase, "section": section_title, "source": f"{BASE_URL}/deprecations#{section_anchor}", "retrieved_at": RETRIEVED_AT, }) i = nxt continue if phase and section_title and l.strip() and not l.startswith("#"): section_text.append(l) i += 1 return entries # -------------------------------------------------------------------------------------- # pricing.md parser # -------------------------------------------------------------------------------------- TIER_WORDS = {"Standard": "standard", "Batch": "batch", "Flex": "flex", "Fast mode": "fast"} GROUP_WORDS = ["Flagship models", "Cyber models", "Multimodal models", "GPT-Live sessions", "Realtime and audio generation models", "Image generation models", "Video generation models", "Transcription models", "Tools", "Specialized models", "Finetuning"] LONG_COLS = ["Short context input", "Short context cached input", "Short context cache writes", "Short context output", "Long context input", "Long context cached input", "Long context cache writes", "Long context output"] LONG_DIM = { "Short context input": ("input", "short"), "Short context cached input": ("cached_input", "short"), "Short context cache writes": ("cache_write", "short"), "Short context output": ("output", "short"), "Long context input": ("input", "long"), "Long context cached input": ("cached_input", "long"), "Long context cache writes": ("cache_write", "long"), "Long context output": ("output", "long"), } def clean_model_cell(cell: str) -> tuple[str, str | None]: c = cell.strip().strip("`") note = None m = re.match(r"^(.*?)\s*\((.*)\)\s*$", c) if m: c, note = m.group(1).strip(), m.group(2).strip() if c == "Whisper": c = "whisper-1" return c, note def parse_pricing_page() -> tuple[list[dict], list[str]]: lines = (DOCS_SRC / "pricing.md").read_text(encoding="utf-8").splitlines() prices: list[dict] = [] footnotes: list[str] = [] group, tier = None, "standard" src = f"{BASE_URL}/pricing" i = 0 def rec(model, dimension, price, unit, **kw): d = {"provider": "openai", "model_or_service": model, "dimension": dimension, "price": price, "currency": "USD", "unit": unit, "tier": kw.pop("tier", tier), "group": group, "effective_notes": kw.pop("effective_notes", None), "source": src, "retrieved_at": RETRIEVED_AT} d.update(kw) prices.append(d) while i < len(lines): l = lines[i] s = l.strip() if s in GROUP_WORDS: group = s tier = "standard" elif s in TIER_WORDS: tier = TIER_WORDS[s] elif s.startswith("|") and not s.startswith("| ---"): header, rows, nxt = md_table(lines, i) h = header if h[0] == "Model" and len(h) == 9 and h[1] == "Short context input": for r in rows: model, note = clean_model_cell(r[0]) for col, cell in zip(h[1:], r[1:]): pm = parse_money(cell) if pm is None: continue dim, ctx = LONG_DIM[col] rec(model, dim, pm[0], "per 1M tokens", context=ctx, effective_notes=(note + "; " if note else "") + ("long context (>272K input tokens) rate" if ctx == "long" else "short context rate")) elif h[:2] == ["Model", "Modality"]: for r in rows: model, note = clean_model_cell(r[0]) modality = r[1].strip().lower() for col, cell in zip(h[2:], r[2:]): pm = parse_money(cell) if pm is None: continue dim = {"Input": "input", "Cached input": "cached_input", "Output": "output", "Output / cost": "output"}[col] rec(model, f"{modality}_{dim}", pm[0], pm[1] or "per 1M tokens", modality=modality, effective_notes=note) elif h[:2] == ["Model", "Size"]: for r in rows: model, _ = clean_model_cell(r[0]) pm = parse_money(r[4]) rec(model, "video_output", pm[0], "per second", size=r[1], portrait=r[2], landscape=r[3]) elif h[:2] == ["Model", "Use case"]: for r in rows: model, _ = clean_model_cell(r[0]) for col, cell in zip(h[2:], r[2:]): pm = parse_money(cell) if pm is None: continue if col == "Input": rec(model, "audio_input", pm[0], "per 1M tokens", use_case=r[1]) elif col == "Output": rec(model, "text_output", pm[0], "per 1M tokens", use_case=r[1]) else: rec(model, "audio_duration", pm[0], pm[1] or "per minute", use_case=r[1], effective_notes="estimated cost per minute of audio") elif h[:2] == ["Tool", "Details"]: for r in rows: tool, details, pricing = r[0], r[1], r[2] for pm_txt in re.findall(r"\$[0-9.,]+(?: / [A-Za-z0-9 ]+)?(?: per [^.;,]+)?", pricing): pass # keep the full pricing string as effective_notes and extract first $ amount pm = parse_money(pricing) unit = pm[1] if pm and pm[1] else None if "GB" in pricing and "session" in pricing: unit = "per 20-minute session per container (by size)" elif "GB-day" in pricing or "GB per day" in pricing: unit = "per GB per day" elif "1k calls" in pricing: unit = "per 1k calls" rec(f"tool:{tool}", details, pm[0] if pm else None, unit or "see notes", tier="standard", effective_notes=pricing) elif h[:2] == ["Category", "Model"]: for r in rows: model, note = clean_model_cell(r[1]) for col, cell in zip(h[2:], r[2:]): pm = parse_money(cell) if pm is None: continue dim = {"Input": "input", "Cached input": "cached_input", "Output": "output"}[col] rec(model, dim, pm[0], "per 1M tokens", category=r[0], effective_notes=note) elif h[:2] == ["Model", "Training"]: for r in rows: model, note = clean_model_cell(r[0]) for col, cell in zip(h[1:], r[1:]): pm = parse_money(cell) if pm is None: continue if col == "Training": rec(model, "fine_tuning_training", pm[0], pm[1] or "per 1M training tokens", group="Finetuning", effective_notes=note) else: dim = {"Input": "input", "Cached input": "cached_input", "Output": "output"}[col] rec(model, f"fine_tuned_{dim}", pm[0], "per 1M tokens", effective_notes=note) elif h[:2] == ["Model", "Price per minute"]: for r in rows: model, _ = clean_model_cell(r[0]) pm = parse_money(r[1]) rec(model, "session_duration", pm[0], "per minute (billed per second)") i = nxt continue elif s and not s.startswith("#") and len(s) > 60 and ("uplift" in s or "alias" in s or "billed" in s or "Billing" in s or "winding down" in s or "Tokens used" in s): footnotes.append(s) i += 1 return prices, footnotes # -------------------------------------------------------------------------------------- # OpenAPI enum mention scan # -------------------------------------------------------------------------------------- MODEL_LIKE = re.compile(r"^(gpt-|o[134]\b|o[134]-|chatgpt-|codex-|dall-e-|tts-1|whisper-1|text-embedding-|omni-moderation|text-moderation|sora-|babbage-002|davinci-002)") def openapi_model_mentions() -> set[str]: ids = set() for l in (ROOT / "sources/openai/openapi/openapi-master.yaml").read_text(encoding="utf-8", errors="replace").splitlines(): m = re.match(r"^\s+- ([A-Za-z0-9.\-]+)\s*$", l) if m and MODEL_LIKE.match(m.group(1)): ids.add(m.group(1)) return ids # -------------------------------------------------------------------------------------- # Build # -------------------------------------------------------------------------------------- def main() -> None: live_raw = json.loads((ROOT / "sources/openai/models-api-raw.json").read_text()) live = {m["id"]: m for m in live_raw["data"]} probes_path = ROOT / "sources/openai/live-model-probes.json" probes = json.loads(probes_path.read_text()) if probes_path.exists() else {"get_models": {}, "responses": {}} pages = {} for p in sorted(MODEL_PAGES.glob("*.md")): if p.stem in ("all", "compare"): continue pg = parse_model_page(p) pages[pg["id"]] = pg deps = parse_deprecations() dep_by_id: dict[str, list[dict]] = defaultdict(list) for e in deps: for mid in e["ids"]: dep_by_id[mid].append(e) prices, footnotes = parse_pricing_page() price_models = {p["model_or_service"] for p in prices} openapi_ids = openapi_model_mentions() # snapshot -> owning page snap_owner: dict[str, str] = {} for mid, pg in pages.items(): for s in pg["snapshots"]: snap_owner.setdefault(s, mid) if pg["default_snapshot"]: snap_owner.setdefault(pg["default_snapshot"], mid) # extra aliases documented in prose ALIAS_TARGET = {"gpt-5.6": "gpt-5.6-sol"} for a, t in ALIAS_TARGET.items(): snap_owner.setdefault(a, t) # id universe universe = set(live) | set(pages) | set(snap_owner) | {i for e in deps if e["kind"] in ("model",) for i in e["ids"]} universe |= {m for m in price_models if not m.startswith("tool:")} universe |= {"gpt-rosalind-research", "gpt-5.5-cyber", "gpt-5.4-cyber"} universe |= openapi_ids universe = {u for u in universe if u and " " not in u and u not in ("gpt-4o-tts",)} live_only = sorted(u for u in live if u not in pages and u not in snap_owner) docs_only = sorted(u for u in pages if u not in live) records = [] for mid in sorted(universe): owner = mid if mid in pages else snap_owner.get(mid) pg = pages.get(owner) if owner else None is_snapshot = pg is not None and mid != owner and is_dated(mid) is_alias = pg is not None and mid != owner and not is_dated(mid) lv = live.get(mid) deps_for = dep_by_id.get(mid, []) status: list[str] = [] flags: list[str] = [] if pg: status.append("DOCUMENTED") elif mid in price_models or mid in ("gpt-rosalind-research", "gpt-5.5-cyber"): status.append("DOCUMENTED") flags.append("NO_MODEL_PAGE") elif mid in openapi_ids and not lv and not deps_for: status.append("DOCUMENTED") flags.append("OPENAPI_ENUM_ONLY") if lv: status.append("LIVE_VERIFIED") if not pg: status.append("LIVE_DISCOVERED") flags.append("DOCUMENTATION_INCOMPLETE") # deprecation / retirement shutdown_dates = [e["shutdown_date"] for e in deps_for if e.get("shutdown_date")] live_sd = lv.get("shutdown_date") if lv else None if live_sd: shutdown_dates.append(live_sd) sd = min(shutdown_dates) if shutdown_dates else None latest_sd = max(shutdown_dates) if shutdown_dates else None if deps_for or live_sd: if latest_sd and latest_sd <= TODAY: status.append("RETIRED") if lv: flags.append("STILL_LISTED_AFTER_DOCUMENTED_SHUTDOWN") else: status.append("DEPRECATED") if "preview" in mid: status.append("PREVIEW") if mid in LEGACY_IDS: status.append("LEGACY") if pg and pg["intro"] and re.search(r"has been deprecated and removed", pg["intro"]) and "RETIRED" not in status: status.append("RETIRED") # probes verification = {"method": "docs_only", "verified_at": RETRIEVED_AT, "result": None, "http_status": None, "request_note": None} if lv: verification = {"method": "live_api", "verified_at": RETRIEVED_AT, "result": "success", "http_status": 200, "request_note": "id present in GET /v1/models listing (2026-09-18)"} gp = probes.get("get_models", {}).get(mid) if gp: verification["get_model"] = gp if gp["status"] == 200: if "LIVE_VERIFIED" not in status: status.append("LIVE_VERIFIED") verification.update({"method": "live_api", "result": "success", "http_status": 200, "request_note": f"GET /v1/models/{mid} -> 200"}) elif gp["status"] in (401, 403) or (gp["status"] == 404 and mid in RESTRICTED_ACCESS): status.append("ACCOUNT_RESTRICTED") verification.update({"method": "live_api", "result": "restricted", "http_status": gp["status"], "request_note": f"GET /v1/models/{mid} -> {gp['status']} {gp.get('error_code') or ''}".strip()}) elif gp["status"] == 404: verification.update({"method": "live_api", "result": "failure", "http_status": 404, "request_note": f"GET /v1/models/{mid} -> 404 (a 404 with our key never means the model does not exist)"}) if "RETIRED" not in status and "DEPRECATED" not in status: flags.append("NOT_RESOLVABLE_WITH_OUR_KEY") rp = probes.get("responses", {}).get(mid) if rp: verification["post_responses"] = rp if rp["status"] == 200: if "LIVE_VERIFIED" not in status: status.append("LIVE_VERIFIED") if gp and gp["status"] == 404: flags = [f for f in flags if f != "NOT_RESOLVABLE_WITH_OUR_KEY"] + ["ALIAS_WORKS_ON_POST_BUT_404_ON_GET_MODELS"] verification.update({"method": "live_api", "result": "success", "http_status": 200, "request_note": f"POST /v1/responses ('Reply with OK.', max_output_tokens 16) -> 200, model echoed: {rp.get('model_echo')}"}) elif rp["status"] in (401, 403): status.append("ACCOUNT_RESTRICTED") else: status.append("FAILED_VERIFICATION") verification.update({"result": "failure", "http_status": rp["status"], "request_note": rp.get("error_message")}) # de-dup keep order status = list(OrderedDict.fromkeys(status)) if not status: status = ["UNVERIFIED"] # ---- fields from page fam = family_of(mid) display = pg["display_name"] if pg and mid == owner else (f"{pg['display_name']} ({'snapshot' if is_snapshot else 'alias'} {mid})" if pg else mid) description = pg["description"] if pg else None if mid == "gpt-rosalind-research": description = "Life sciences reasoning for approved organizations (trusted-access program)." if mid == "gpt-5.5-cyber": description = "Cybersecurity model listed on the pricing page (Daybreak)." if mid == "gpt-5.4-cyber": description = "Cybersecurity model, deprecated 2026-09-11, shutdown 2026-10-01 (replacement gpt-5.6-cyber)." snapshots = [s for s in (pg["snapshots"] if pg else []) if is_dated(s)] aliases = [s for s in (pg["snapshots"] if pg else []) if not is_dated(s) and s != mid] for a, t in ALIAS_TARGET.items(): if t == mid: aliases.append(a) if mid == "gpt-5.6-sol": aliases.append("gpt-daybreak-blue-latest") if mid == "gpt-5.6-cyber": aliases.append("gpt-daybreak-red-latest") aliases = sorted(set(aliases)) # release date rd, rd_src = None, None if mid in RELEASE_DATES: rd, rd_src = RELEASE_DATES[mid], "changelog" elif owner in RELEASE_DATES and is_alias: rd, rd_src = RELEASE_DATES[owner], "changelog (parent model)" elif mid in RELEASE_DATES_EXTERNAL: rd, rd_src = RELEASE_DATES_EXTERNAL[mid], "public OpenAI announcement (not in downloaded docs)" elif is_snapshot and re.search(r"(\d{4}-\d{2}-\d{2})$", mid): rd, rd_src = re.search(r"(\d{4}-\d{2}-\d{2})$", mid).group(1), "snapshot date in id" elif lv: rd, rd_src = datetime.fromtimestamp(lv["created"], tz=timezone.utc).strftime("%Y-%m-%d"), "GET /v1/models created timestamp (approximation)" in_mod = pg["input_modalities"] if pg else [] out_mod = pg["output_modalities"] if pg else [] feats = set(pg["features"]) if pg else set() unsup = set(pg["unsupported_features"]) if pg else set() tools = set(pg["tools"]) if pg else set() eps_sup = {e["name"] for e in pg["endpoints"] if e["supported"]} if pg else set() has_feats = bool(pg and pg["has_features_section"]) has_tools = bool(pg and pg["has_tools_section"]) supports_responses = "Responses" in eps_sup def feat(name): if not pg: return "unknown" if name in feats: return True if name in unsup: return False return False if has_feats else "unknown" def tool(name): if not pg: return "unknown" if name in tools: return True if has_tools: return False return False if not supports_responses else "unknown" caps = OrderedDict() if pg: caps["text_in"] = "text" in in_mod caps["text_out"] = "text" in out_mod caps["image_in"] = ("image" in in_mod) or ("image_input" in feats) caps["image_out"] = "image" in out_mod caps["audio_in"] = "audio" in in_mod caps["audio_out"] = "audio" in out_mod caps["video_in"] = "video" in in_mod caps["video_out"] = "video" in out_mod caps["reasoning"] = pg["reasoning"] caps["reasoning_effort_values"] = pg["reasoning_effort"] if pg["reasoning"] else None caps["reasoning_effort_default"] = pg.get("reasoning_effort_default") caps["reasoning_mode_pro"] = True if owner in PRO_MODE_MODELS else ("unknown" if pg["reasoning"] and fam in ("gpt-6",) else False) caps["streaming"] = feat("streaming") caps["structured_outputs"] = feat("structured_outputs") caps["function_calling"] = feat("function_calling") if "function_calling" in feats or has_feats else ("function_calling" in tools or "unknown") caps["prompt_caching"] = feat("prompt_caching") caps["extended_prompt_cache_retention_24h"] = True if (owner in EXTENDED_CACHE_RETENTION or fam in ("gpt-5.6", "gpt-6", "cyber-daybreak")) else ("unknown" if caps["prompt_caching"] is True else False) caps["explicit_cache_breakpoints"] = True if fam in ("gpt-5.6", "gpt-6", "cyber-daybreak") else False caps["predicted_outputs"] = feat("predicted_outputs") caps["file_uploads"] = feat("file_uploads") caps["evals"] = feat("evals") caps["stored_completions"] = feat("stored_completions") caps["distillation"] = feat("stored_completions") caps["inpainting"] = feat("inpainting") if fam == "image" else False caps["fine_tuning"] = True if ("fine_tuning" in feats or "Fine-tuning" in eps_sup) else (False if "fine_tuning" in unsup or has_feats or pg["endpoints"] else "unknown") caps["batch"] = "Batch" in eps_sup caps["embeddings"] = "Embeddings" in eps_sup caps["realtime"] = "Realtime" in eps_sup caps["live_sessions"] = "Live" in eps_sup caps["moderation"] = "Moderation" in eps_sup caps["image_generation_api"] = "Image generation" in eps_sup caps["image_edit_api"] = "Image edit" in eps_sup caps["video_generation"] = "Videos" in eps_sup caps["speech_generation"] = "Speech generation" in eps_sup caps["transcription"] = "Transcription" in eps_sup or "Realtime transcription" in eps_sup caps["translation"] = "Translation" in eps_sup or "Realtime translation" in eps_sup caps["completions_legacy"] = "Completions (legacy)" in eps_sup caps["compaction"] = True if mid in COMPACTION_MODELS or owner in COMPACTION_MODELS else "unknown" caps["long_context_tier_272k"] = bool(pg["context_window"] and pg["context_window"] > 400000) # tools (Responses API) caps["tool_web_search"] = tool("web_search") if tool("web_search") != "unknown" else ("web_search" in feats or "unknown") caps["tool_file_search"] = tool("file_search") if tool("file_search") != "unknown" else ("file_search" in feats or "unknown") caps["tool_code_interpreter"] = tool("code_interpreter") caps["tool_image_generation"] = tool("image_generation") if tool("image_generation") != "unknown" else ("image_generation" in feats or "unknown") caps["tool_mcp"] = tool("mcp") if tool("mcp") != "unknown" else ("mcp" in feats or "unknown") caps["tool_computer_use"] = tool("computer_use") if mid != "computer-use-preview" and owner != "computer-use-preview" else True caps["tool_hosted_shell"] = tool("hosted_shell") caps["tool_apply_patch"] = tool("apply_patch") caps["tool_skills"] = tool("skills") caps["tool_tool_search"] = tool("tool_search") caps["tool_function_calling"] = tool("function_calling") if has_tools else caps["function_calling"] caps["web_search_feature"] = "web_search" in feats else: caps["note"] = "no model page; capabilities unknown" service_tiers = {t: any(p["model_or_service"] == (owner or mid) and p["tier"] == t for p in prices) for t in ("standard", "batch", "flex", "fast")} if pg and "Batch" in eps_sup: service_tiers["batch"] = True # pricing object pricing = OrderedDict() for p in prices: if p["model_or_service"] != (owner or mid): continue tier = p["tier"] ctx = p.get("context") key = tier if not ctx or ctx == "short" else f"{tier}_long_context" pricing.setdefault(key, OrderedDict()) dim = p["dimension"] if p.get("modality") and not dim.startswith(p["modality"]): dim = f"{p['modality']}_{dim}" if p.get("size"): dim = f"video_output_{p['size']}" pricing[key][dim] = p["price"] pricing[key].setdefault("_unit", p["unit"]) if pg: for sub, rows in pg["pricing_tables"].items(): key = "model_page:" + sub pricing[key] = OrderedDict((r["metric"], {"price": r["price"], "unit": r["unit"]}) for r in rows if r["price"] is not None) if pg["pricing_notes"]: pricing["notes"] = pg["pricing_notes"] if not pricing: pricing["note"] = "no price found in pricing.md or model page (retired / not billed / open-weight)" endpoints = [{"name": e["name"], "route": "/" + e["route"]} for e in pg["endpoints"] if e["supported"]] if pg else [] rate_limits = OrderedDict() if pg: rate_limits["documented_tiers"] = pg["rate_limits"] if pg["rate_limit_notes"]: rate_limits["notes"] = pg["rate_limit_notes"] rate_limits["source"] = f"{BASE_URL}/models/{owner}" rate_limits["observed"] = "see generated/fragments/rate-limits/openai-rate-limits.json#observed (never generalize)" # deprecation summary dep_summary = [] for e in deps_for: dep_summary.append({"announced": e["announced"], "shutdown_date": e["shutdown_date"], "replacement": e["replacement"], "phase": e["phase"], "section": e["section"], "source": e["source"]}) if live_sd: dep_summary.append({"announced": None, "shutdown_date": live_sd, "replacement": None, "phase": "live", "section": "GET /v1/models shutdown_date field", "source": "https://api.openai.com/v1/models"}) doc_sd = [e["shutdown_date"] for e in deps_for if e.get("shutdown_date")] if live_sd and doc_sd and live_sd not in doc_sd: flags.append("SHUTDOWN_DATE_MISMATCH_DOCS_VS_LIVE") if live_sd and not doc_sd: flags.append("SHUTDOWN_DATE_ONLY_IN_LIVE_API") sources = [] if pg: sources.append({"url": f"{BASE_URL}/models/{owner}", "retrieved_at": RETRIEVED_AT}) if lv: sources.append({"url": "https://api.openai.com/v1/models", "retrieved_at": RETRIEVED_AT, "note": "GET /v1/models (live listing)"}) if (owner or mid) in price_models: sources.append({"url": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}) for e in deps_for: if not any(s["url"] == e["source"] for s in sources): sources.append({"url": e["source"], "retrieved_at": RETRIEVED_AT}) if mid in openapi_ids: sources.append({"url": OPENAPI_URL, "retrieved_at": RETRIEVED_AT, "note": "id appears in an OpenAPI enum"}) if mid in RELEASE_DATES: sources.append({"url": f"{BASE_URL}/changelog", "retrieved_at": RETRIEVED_AT}) availability = OrderedDict() availability["account"] = RESTRICTED_ACCESS.get(mid) or RESTRICTED_ACCESS.get(owner or "") or "general (usage-tier based)" availability["service_tiers"] = service_tiers availability["listed_in_get_models"] = bool(lv) availability["owned_by"] = lv["owned_by"] if lv else None availability["created_ts"] = lv["created"] if lv else None availability["openapi_enum"] = mid in openapi_ids if fam == "open-weight": availability["distribution"] = "open weights on Hugging Face (Apache 2.0); documented Responses/Batch endpoint table, rate limits 0 for all tiers" rec = OrderedDict([ ("provider", "openai"), ("id", mid), ("record_kind", "snapshot" if is_snapshot else ("alias" if is_alias else ("model" if pg else "id_only"))), ("canonical_model", owner or mid), ("display_name", display), ("description", description), ("aliases", aliases if mid == owner else []), ("snapshots", snapshots if mid == owner else []), ("default_snapshot", pg["default_snapshot"] if pg else None), ("family", fam), ("status", status), ("flags", flags), ("release_date", rd), ("release_date_source", rd_src), ("knowledge_cutoff", pg["knowledge_cutoff"] if pg else None), ("context_window", pg["context_window"] if pg else None), ("max_input", pg["max_input"] if pg else None), ("max_output", pg["max_output"] if pg else None), ("modalities", {"input": in_mod, "output": out_mod, "unsupported": pg["unsupported_modalities"] if pg else []}), ("capabilities", caps), ("endpoints", endpoints), ("tools", sorted(tools)), ("features_documented", sorted(feats)), ("pricing", pricing), ("rate_limits", rate_limits), ("beta_headers", []), ("restrictions", RESTRICTED_ACCESS.get(mid) or RESTRICTED_ACCESS.get(owner or "")), ("availability", availability), ("deprecation", dep_summary), ("shutdown_date", latest_sd), ("intro", pg["intro"][:600] if pg and mid == owner else None), ("last_verified", RETRIEVED_AT), ("verification", verification), ("sources", sources), ]) records.append(rec) # ---- additional price records from model pages not covered by pricing.md page_prices = [] for mid, pg in pages.items(): if mid in price_models: continue for sub, rows in pg["pricing_tables"].items(): for r in rows: if r["price"] is None: continue dim_map = {"Input": "input", "Cached input": "cached_input", "Output": "output", "Cache writes": "cache_write", "Cost": "cost", "Price": "audio_duration", "Per minute": "session_duration"} dim = dim_map.get(r["metric"], r["metric"].lower().replace(" ", "_")) prefix = {"Audio tokens": "audio_", "Image tokens": "image_", "Video generation": "video_output_", "Embeddings": "embeddings_"}.get(sub, "") page_prices.append({"provider": "openai", "model_or_service": mid, "dimension": prefix + dim if not dim.startswith(prefix) else dim, "price": r["price"], "currency": "USD", "unit": "per " + r["unit"] if not r["unit"].startswith("per") else r["unit"], "tier": "standard", "group": "model page", "effective_notes": "; ".join(pg["pricing_notes"]) or None, "source": f"{BASE_URL}/models/{mid}", "retrieved_at": RETRIEVED_AT}) all_prices = prices + page_prices # derived tier records (documented rules) rules = [ {"provider": "openai", "model_or_service": "rule:batch", "dimension": "discount", "price": -50, "currency": "USD", "unit": "percent vs standard", "tier": "batch", "effective_notes": "Batch API: 50% lower cost, 24h completion window; per-model batch tables on the pricing page", "source": f"{BASE_URL}/guides/batch", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:flex", "dimension": "discount", "price": -50, "currency": "USD", "unit": "percent vs standard", "tier": "flex", "effective_notes": "Flex processing (beta): tokens priced at Batch API rates; slower, may return 429 resource_unavailable", "source": f"{BASE_URL}/guides/flex-processing", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:fast", "dimension": "premium", "price": 100, "currency": "USD", "unit": "percent vs standard", "tier": "fast", "effective_notes": "Fast mode (ex-Priority processing, renamed 2026-07-30): 2x standard token rates for GPT-5.6 Sol / GPT-6 Astra; service_tier 'fast' or 'priority'", "source": f"{BASE_URL}/guides/fast-mode", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:cache_write", "dimension": "cache_write", "price": 125, "currency": "USD", "unit": "percent of uncached input rate", "tier": "standard", "effective_notes": "GPT-5.6 and later: cache writes billed at 1.25x uncached input; earlier models: no cache-write charge", "source": f"{BASE_URL}/guides/prompt-caching", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:cache_read_gpt-5.6+", "dimension": "cached_input", "price": 10, "currency": "USD", "unit": "percent of uncached input rate", "tier": "standard", "effective_notes": "GPT-5.6 and later: cache reads at 0.1x uncached input rate", "source": f"{BASE_URL}/guides/prompt-caching", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:long_context", "dimension": "multiplier", "price": None, "currency": "USD", "unit": "x standard", "tier": "standard", "effective_notes": "Prompts >272K input tokens: 2x input (and cache) rates, 1.5x output rate for the full request (GPT-5.4 / 5.5 / 5.6 / 6 Astra)", "source": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:data_residency", "dimension": "uplift", "price": 10, "currency": "USD", "unit": "percent", "tier": "standard", "effective_notes": "Regional processing endpoints (us./eu./ae. …api.openai.com): 10% uplift for models released on or after 2026-03-05 that are eligible for data residency", "source": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:container_billing", "dimension": "session", "price": None, "currency": "USD", "unit": "per minute, 5-minute minimum", "tier": "standard", "effective_notes": "Since 2026-06-02 eligible container sessions (Code Interpreter / Hosted Shell) are billed per minute with a 5-minute minimum instead of the full 20-minute rate", "source": f"{BASE_URL}/changelog", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:web_search_content_tokens_mini", "dimension": "input", "price": None, "currency": "USD", "unit": "8,000 input tokens per call", "tier": "standard", "effective_notes": "gpt-4o-mini and gpt-4.1-mini with the non-preview web search tool: search content tokens billed as a fixed block of 8,000 input tokens per call", "source": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "rule:pro_mode", "dimension": "output", "price": None, "currency": "USD", "unit": "standard token rates", "tier": "standard", "effective_notes": "reasoning.mode: pro (GPT-5.6) aggregates all model work and bills it at the model's standard token rates (more tokens than standard mode)", "source": f"{BASE_URL}/guides/reasoning", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "gpt-rosalind-research", "dimension": "billing_start", "price": None, "currency": "USD", "unit": "date", "tier": "standard", "effective_notes": "Billing begins 2026-10-05; cache-write pricing does not apply", "source": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}, {"provider": "openai", "model_or_service": "gpt-5.6-sol", "dimension": "promotion", "price": None, "currency": "USD", "unit": "note", "tier": "standard", "effective_notes": "Promotional pricing ($4 in / $20 out) available at least through 2026-11-21", "source": f"{BASE_URL}/pricing", "retrieved_at": RETRIEVED_AT}, ] for r in rules: r.setdefault("group", "rules") all_prices += rules # ---- rate limits fragment rl_fragment = OrderedDict([ ("provider", "openai"), ("retrieved_at", RETRIEVED_AT), ("sources", [f"{BASE_URL}/guides/rate-limits", f"{BASE_URL}/models/ (Rate limits section)", f"{BASE_URL}/guides/batch", f"{BASE_URL}/guides/fast-mode"]), ("concepts", [ {"metric": "RPM", "meaning": "requests per minute", "scope": "organization and project, per model (or shared-limit group)"}, {"metric": "RPD", "meaning": "requests per day", "scope": "some models / free tier"}, {"metric": "TPM", "meaning": "tokens per minute (max(max_tokens, estimated prompt tokens) counted per request)", "scope": "per model"}, {"metric": "TPD", "meaning": "tokens per day", "scope": "some models"}, {"metric": "IPM", "meaning": "images per minute", "scope": "image models (gpt-image-*)"}, {"metric": "minutes-of-audio per minute", "meaning": "audio duration admitted per minute", "scope": "streaming audio models (gpt-realtime-whisper, gpt-realtime-translate)"}, {"metric": "concurrent sessions", "meaning": "simultaneous live sessions", "scope": "gpt-live-1 (v1/live/sessions)"}, {"metric": "batch queue limit", "meaning": "total input tokens queued across pending batch jobs for a model; released when the batch completes", "scope": "Batch API, per model"}, {"metric": "long-context limit", "meaning": "separate RPM/TPM/batch-queue table for requests >272K input tokens (GPT-5.4/5.5/5.6/6 Astra)", "scope": "long-context models; visible in the console"}, {"metric": "shared limits", "meaning": "some model families share one limit pool (listed under 'shared limit' in the console)", "scope": "organization"}, {"metric": "project-scoped token limit", "meaning": "optional per-project token limit exposed via x-ratelimit-*-project-tokens headers", "scope": "project"}, {"metric": "monthly usage limit", "meaning": "approved monthly spend ceiling per organization, separate from configurable spend limits", "scope": "organization"}, {"metric": "vector store ingestion", "meaning": "/vector_stores/{id}/files and /file_batches share 300 requests per minute per vector store", "scope": "per vector store"}, {"metric": "ramp-rate (slow_down)", "meaning": "429 rate_limit_error/slow_down when traffic increases too quickly even below RPM/TPM; above ~1M TPM ramp at most +50% every 15 minutes", "scope": "per model"}, ]), ("usage_tiers", [ {"tier": "Free", "qualification": "allowed geography", "usage_limit_usd_per_month": 100}, {"tier": "Tier 1", "qualification": "$5 paid", "usage_limit_usd_per_month": 100}, {"tier": "Tier 2", "qualification": "$50 paid", "usage_limit_usd_per_month": 500}, {"tier": "Tier 3", "qualification": "$100 paid", "usage_limit_usd_per_month": 1000}, {"tier": "Tier 4", "qualification": "$250 paid", "usage_limit_usd_per_month": 5000}, {"tier": "Tier 5", "qualification": "$1,000 paid", "usage_limit_usd_per_month": 200000}, ]), ("headers", [ {"name": "Retry-After", "sample": "56", "description": "minimum seconds to wait before retrying a temporary 429 (slow_down / rate limit) or 503 (server_is_overloaded)"}, {"name": "x-ratelimit-limit-requests", "sample": "60", "description": "max requests before exhausting the rate limit"}, {"name": "x-ratelimit-limit-tokens", "sample": "150000", "description": "max tokens before exhausting the rate limit"}, {"name": "x-ratelimit-remaining-requests", "sample": "59", "description": "remaining requests"}, {"name": "x-ratelimit-remaining-tokens", "sample": "149984", "description": "remaining tokens"}, {"name": "x-ratelimit-reset-requests", "sample": "1s", "description": "time until request limit resets"}, {"name": "x-ratelimit-reset-tokens", "sample": "6m0s", "description": "time until token limit resets"}, {"name": "x-ratelimit-limit-project-tokens", "sample": "60000", "description": "project token limit (present when a project-scoped limit applies)"}, {"name": "x-ratelimit-remaining-project-tokens", "sample": "57000", "description": "remaining project tokens"}, {"name": "x-ratelimit-reset-project-tokens", "sample": "3s", "description": "time until project token limit resets"}, ]), ("errors", [ {"http_status": 429, "type": "rate_limit_error", "code": "slow_down", "meaning": "request rate increased too quickly", "action": "honour Retry-After, reduce rate, ramp gradually"}, {"http_status": 429, "type": "rate_limit_error", "code": "rate_limit_exceeded", "meaning": "RPM/TPM/RPD/TPD/IPM exhausted", "action": "exponential backoff with jitter; batch requests; reduce max_tokens"}, {"http_status": 429, "type": "insufficient_quota", "code": "insufficient_quota", "meaning": "monthly usage / spend limit reached (429 also returned when a hard spend limit is hit)", "action": "do not retry; raise limits"}, {"http_status": 503, "type": "service_unavailable_error", "code": "server_is_overloaded", "meaning": "model temporarily overloaded", "action": "honour Retry-After, retry with increasing delay"}, ]), ("fine_tuning_limits_endpoint", "GET /v1/fine_tuning/model_limits"), ("capacity_products", ["Scale Tier (pay-as-you-go traffic routinely hitting ramp limits)", "Reserved Tier (GPT-5.6 and later)", "Ultrafast mode (limited preview, GPT-5.6 Sol, announced 2026-08-13)"]), ("per_model_documented_tiers", OrderedDict((mid, pg["rate_limits"]) for mid, pg in sorted(pages.items()) if pg["rate_limits"])), ("per_model_notes", OrderedDict((mid, pg["rate_limit_notes"]) for mid, pg in sorted(pages.items()) if pg["rate_limit_notes"])), ("observed", [ {"date": RETRIEVED_AT, "request": "POST /v1/responses (this atlas key)", "headers": {"x-ratelimit-limit-requests": "30000", "x-ratelimit-limit-tokens": "180000000"}, "note": "observed for OUR key/org/project on 2026-09-18 — account-specific, do not generalize"}, ] + probes.get("observed_headers", [])), ]) # ---- deprecations fragment: add live shutdown dates not on the page dep_fragment = list(deps) doc_ids_with_sd = {i for e in deps for i in e["ids"] if e.get("shutdown_date")} for mid, lv in sorted(live.items()): if lv.get("shutdown_date"): page_dates = {e["shutdown_date"] for e in dep_by_id.get(mid, []) if e.get("shutdown_date")} dep_fragment.append({ "provider": "openai", "kind": "model", "subject": mid, "ids": [mid], "primary_id": mid, "announced": None, "shutdown_date": lv["shutdown_date"], "shutdown_raw": lv["shutdown_date"], "replacement": None, "legacy_price": None, "phase": "live_api_field", "section": "GET /v1/models shutdown_date", "consistency": ("matches deprecations page" if lv["shutdown_date"] in page_dates else ("NOT on deprecations page" if not page_dates else f"differs from page dates {sorted(page_dates)}")), "source": "https://api.openai.com/v1/models", "retrieved_at": RETRIEVED_AT}) out = ROOT / "generated/fragments" (out / "models").mkdir(parents=True, exist_ok=True) (out / "pricing").mkdir(parents=True, exist_ok=True) (out / "rate-limits").mkdir(parents=True, exist_ok=True) (out / "deprecations").mkdir(parents=True, exist_ok=True) meta = {"generated_by": "scripts/build_openai_models.py", "retrieved_at": RETRIEVED_AT, "counts": {"records": len(records), "live_ids": len(live), "model_pages": len(pages), "live_only_ids": live_only, "docs_only_ids": docs_only}} (out / "models/openai-models.json").write_text(json.dumps(records, indent=1, ensure_ascii=False) + "\n") (out / "models/openai-models.meta.json").write_text(json.dumps(meta, indent=1) + "\n") (out / "pricing/openai-pricing.json").write_text(json.dumps(all_prices, indent=1, ensure_ascii=False) + "\n") (out / "rate-limits/openai-rate-limits.json").write_text(json.dumps(rl_fragment, indent=1, ensure_ascii=False) + "\n") (out / "deprecations/openai-deprecations.json").write_text(json.dumps(dep_fragment, indent=1, ensure_ascii=False) + "\n") write_docs(records, pages, all_prices, footnotes, dep_fragment, live_only, docs_only, probes) print(f"records={len(records)} prices={len(all_prices)} deprecations={len(dep_fragment)} live_only={len(live_only)} docs_only={len(docs_only)}") # -------------------------------------------------------------------------------------- # Docs # -------------------------------------------------------------------------------------- def fmt(v): if v is True: return "✅" if v is False: return "—" if v is None: return "" if isinstance(v, list): return ", ".join(map(str, v)) return str(v) def money(v): return "" if v is None else (f"${v:g}") def write_docs(records, pages, prices, footnotes, deps, live_only, docs_only, probes): by_id = {r["id"]: r for r in records} canon = [r for r in records if r["record_kind"] in ("model", "id_only")] fam_order = ["gpt-6", "gpt-5.6", "cyber-daybreak", "gpt-5.5", "gpt-5.4", "gpt-5.2", "gpt-5.1", "gpt-5", "codex", "o-series", "computer-use", "search", "chatgpt-latest", "life-sciences", "gpt-4.1", "gpt-4.5", "gpt-4o", "realtime", "live", "audio-chat", "speech-to-text", "text-to-speech", "image", "video", "embeddings", "moderation", "open-weight", "gpt-4", "gpt-3.5", "base-legacy", "legacy-retired"] fams = defaultdict(list) for r in canon: fams[r["family"]].append(r) L = [] L.append("# OpenAI — Model catalogue (API Atlas)\n") L.append("**Status:** DOCUMENTED + LIVE_VERIFIED (GET /v1/models listing on 2026-09-18, 136 ids; plus targeted GET /v1/models/{id} and minimal POST /v1/responses probes). Machine-readable twin: `generated/fragments/models/openai-models.json`.\n") L.append("**Sources:** https://developers.openai.com/api/docs/models · https://developers.openai.com/api/docs/models/ (101 pages) · https://developers.openai.com/api/docs/pricing · https://developers.openai.com/api/docs/deprecations · https://developers.openai.com/api/docs/changelog · https://api.openai.com/v1/models · OpenAPI spec (openapi-master.yaml).\n") L.append("**Last verified:** 2026-09-18\n") live_n = sum(1 for r in records if "LIVE_VERIFIED" in r["status"]) L.append(f"Records: **{len(records)}** ids ({len(canon)} canonical models/ids, {sum(1 for r in records if r['record_kind']=='snapshot')} dated snapshots, " f"{sum(1 for r in records if r['record_kind']=='alias')} aliases). LIVE_VERIFIED: {live_n}. Status vocabulary per CLAUDE.md; extra markers live in `flags[]` " "(`DOCUMENTATION_INCOMPLETE`, `STILL_LISTED_AFTER_DOCUMENTED_SHUTDOWN`, `SHUTDOWN_DATE_ONLY_IN_LIVE_API`, `OPENAPI_ENUM_ONLY`, `NO_MODEL_PAGE`).\n") L.append("## How to read\n\n- `LIVE_VERIFIED` = id returned by `GET /v1/models` with our key on 2026-09-18 (or a probe succeeded). `DOCUMENTED` = has an official model page (or pricing/deprecation entry). `DEPRECATED` = shutdown date announced and in the future; `RETIRED` = shutdown date passed (docs) — several such ids are **still listed live** (flag `STILL_LISTED_AFTER_DOCUMENTED_SHUTDOWN`).\n- Capability cells: ✅ documented supported · — documented absent (page has a features/tools section but does not list it, or endpoint table says Not supported) · `?` unknown (no page or no section).\n- Prices are USD per 1M tokens (standard tier) unless noted; see `docs/openai/pricing.md`.\n") # live-only / docs-only L.append("## Discrepancies: live-only ids (in GET /v1/models, no model page)\n") L.append("| id | family | status | created (live) | shutdown_date (live) | note |\n|---|---|---|---|---|---|") for mid in live_only: r = by_id[mid] L.append(f"| `{mid}` | {r['family']} | {', '.join(r['status'])} | {r['release_date']} | {r['shutdown_date'] or ''} | {'; '.join(r['flags'])} |") L.append("\n## Discrepancies: docs-only ids (model page exists, NOT in GET /v1/models)\n") L.append("| id | family | status | GET /v1/models/{id} probe | note |\n|---|---|---|---|---|") for mid in docs_only: r = by_id[mid] gp = probes.get("get_models", {}).get(mid) probe = f"HTTP {gp['status']} {gp.get('error_code') or ''}".strip() if gp else "not probed" note = r["restrictions"] or ("; ".join(d["section"] for d in r["deprecation"][:1]) if r["deprecation"] else "") L.append(f"| `{mid}` | {r['family']} | {', '.join(r['status'])} | {probe} | {note} |") # live probe summary L.append("\n## Live probes (2026-09-18)\n") L.append("| id | GET /v1/models/{id} | POST /v1/responses | model echoed | error |\n|---|---|---|---|---|") probe_ids = sorted(set(probes.get("get_models", {})) | set(probes.get("responses", {}))) for mid in probe_ids: gp = probes.get("get_models", {}).get(mid) rp = probes.get("responses", {}).get(mid) L.append(f"| `{mid}` | {('HTTP ' + str(gp['status'])) if gp else ''} | {('HTTP ' + str(rp['status'])) if rp else ''} | {rp.get('model_echo','') if rp else ''} | " f"{(gp.get('error_code') or '') if gp else ''} {(rp.get('error_message') or '')[:120] if rp else ''} |") # per family sections L.append("\n## Catalogue by family\n") for fam in fam_order + sorted(set(fams) - set(fam_order)): rs = fams.get(fam) if not rs: continue L.append(f"\n### {fam}\n") L.append("| id | display name | status | release | cutoff | context | max out | in → out | std price in/cached/out ($/1M) | snapshots | shutdown |\n|---|---|---|---|---|---|---|---|---|---|---|") for r in sorted(rs, key=lambda x: (x["release_date"] or "0000"), reverse=True): std = r["pricing"].get("standard", {}) pin = std.get("input"); pc = std.get("cached_input"); po = std.get("output") if pin is None and "model_page:Text tokens" in r["pricing"]: t = r["pricing"]["model_page:Text tokens"] pin = t.get("Input", {}).get("price"); pc = t.get("Cached input", {}).get("price"); po = t.get("Output", {}).get("price") price = f"{money(pin)} / {money(pc)} / {money(po)}" if (pin is not None or po is not None) else "" mods = f"{'+'.join(r['modalities']['input'])} → {'+'.join(r['modalities']['output'])}" if r["modalities"]["input"] else "" L.append(f"| `{r['id']}` | {r['display_name']} | {', '.join(r['status'])} | {r['release_date'] or ''} | {r['knowledge_cutoff'] or ''} | " f"{r['context_window'] or ''} | {r['max_output'] or ''} | {mods} | {price} | {', '.join('`'+s+'`' for s in r['snapshots'])} | {r['shutdown_date'] or ''} |") # capability matrix cap_cols = ["reasoning", "reasoning_effort_values", "streaming", "structured_outputs", "function_calling", "prompt_caching", "extended_prompt_cache_retention_24h", "predicted_outputs", "fine_tuning", "batch", "image_in", "audio_in", "audio_out", "image_out", "video_out", "embeddings", "realtime", "compaction", "long_context_tier_272k", "tool_web_search", "tool_file_search", "tool_code_interpreter", "tool_image_generation", "tool_mcp", "tool_computer_use", "tool_hosted_shell", "tool_apply_patch", "tool_skills", "tool_tool_search"] L.append("\n## Model × Capability (documented models with a page)\n") L.append("| id | " + " | ".join(c.replace("tool_", "🛠") for c in cap_cols) + " |") L.append("|---|" + "---|" * len(cap_cols)) for r in sorted(canon, key=lambda x: (fam_order.index(x["family"]) if x["family"] in fam_order else 99, x["id"])): if r["record_kind"] != "model": continue c = r["capabilities"] L.append(f"| `{r['id']}` | " + " | ".join(("?" if c.get(k) == "unknown" else fmt(c.get(k))) for k in cap_cols) + " |") # endpoint matrix ep_names = ["Responses", "Chat Completions", "Batch", "Realtime", "Realtime transcription", "Realtime translation", "Live", "Assistants", "Fine-tuning", "Embeddings", "Image generation", "Image edit", "Videos", "Speech generation", "Transcription", "Translation", "Moderation", "Completions (legacy)"] L.append("\n## Model × Endpoint (documented models with a page)\n") L.append("| id | " + " | ".join(ep_names) + " |") L.append("|---|" + "---|" * len(ep_names)) for r in sorted(canon, key=lambda x: (fam_order.index(x["family"]) if x["family"] in fam_order else 99, x["id"])): if r["record_kind"] != "model": continue names = {e["name"] for e in r["endpoints"]} L.append(f"| `{r['id']}` | " + " | ".join("✅" if n in names else "—" for n in ep_names) + " |") L.append("\n## Snapshot / alias index\n") L.append("| id | kind | canonical model | status | shutdown |\n|---|---|---|---|---|") for r in records: if r["record_kind"] in ("snapshot", "alias"): L.append(f"| `{r['id']}` | {r['record_kind']} | `{r['canonical_model']}` | {', '.join(r['status'])} | {r['shutdown_date'] or ''} |") L.append("\n## Decisions & caveats\n") L.append("- `DOCUMENTATION_INCOMPLETE` is not part of the CLAUDE.md status vocabulary, so it is recorded in `flags[]` (status keeps `LIVE_VERIFIED`+`LIVE_DISCOVERED`).\n" "- `LEGACY` is assigned editorially (models the docs call *older*, *legacy* or *previous generation*, plus their snapshots); OpenAI's own definition is \"no longer receives updates\".\n" "- Release dates: changelog when available, else the `created` timestamp of GET /v1/models (labelled `release_date_source`). gpt-oss dates come from the public announcement (not in the downloaded docs).\n" "- The live `GET /v1/models` payload exposes a `shutdown_date` field per model (not documented on the reference page). It is recorded in `deprecation[]` with `phase: live` and cross-checked with the deprecations page.\n" "- Capability `false` means *the page has a features/tools section and does not list it*; it is not an experimental negative.\n" "- Rate-limit tables are copied verbatim from the model pages (documented tiers). Our observed headers are account-specific and kept separate.\n") (ROOT / "docs/models").mkdir(parents=True, exist_ok=True) (ROOT / "docs/models/openai-models.md").write_text("\n".join(L) + "\n") # ---------------- pricing doc P = [] P.append("# OpenAI — Pricing (API Atlas)\n") P.append("**Status:** DOCUMENTED (prices copied from the official pricing page and model pages; not billed-verified beyond the minimal probes). Machine-readable twin: `generated/fragments/pricing/openai-pricing.json`.\n") P.append("**Sources:** https://developers.openai.com/api/docs/pricing · https://developers.openai.com/api/docs/models/ · https://developers.openai.com/api/docs/guides/batch · https://developers.openai.com/api/docs/guides/flex-processing · https://developers.openai.com/api/docs/guides/fast-mode · https://developers.openai.com/api/docs/guides/prompt-caching · https://developers.openai.com/api/docs/guides/your-data\n") P.append("**Last verified:** 2026-09-18\n") P.append("All prices USD. Token prices are per 1M tokens. `cached_input` = cache read; `cache_write` (GPT-5.6+/GPT-6 only) = 1.25× uncached input. Long context = prompts >272K input tokens (2× input/cache, 1.5× output for the whole request).\n") P.append("## Service tiers\n\n| Tier | `service_tier` | Price rule | Notes |\n|---|---|---|---|\n" "| Standard | `default` / omitted | list price | |\n" "| Batch | Batch API (`/v1/batches`, `completion_window: 24h`) | 50% of standard | separate, much higher queue limits; 24h turnaround |\n" "| Flex | `flex` | Batch rates (50%) | beta, limited models; slower; may return 429 `resource_unavailable`; raise client timeout (15 min recommended) |\n" "| Fast (ex-Priority) | `fast` or `priority` | 2× standard for GPT-5.6 Sol / GPT-6 Astra (per-model table) | renamed 2026-07-30; up to 2.5× faster; downgraded requests return `service_tier: default` and standard rates; no fine-tuned models/embeddings; unavailable for GPT-6 Astra with EU data residency |\n" "| Ultrafast | — | — | limited preview for GPT-5.6 Sol (announced 2026-08-13), up to 14× faster |\n" "| Regional processing | `us.`/`eu.`/`ae.` … prefixed domains | +10% uplift | models released on/after 2026-03-05 that are data-residency eligible |\n") def table(group_filter, cols, title, tier=None, ctx=None): rows = defaultdict(dict) for p in prices: if p.get("group") != group_filter: continue if tier and p["tier"] != tier: continue if ctx and p.get("context") != ctx: continue if not ctx and p.get("context") == "long": continue key = p["model_or_service"] + (f" [{p['modality']}]" if p.get("modality") else "") + (f" [{p['size']}]" if p.get("size") else "") rows[key][p["dimension"]] = p["price"] rows[key]["_unit"] = p["unit"] rows[key]["_note"] = p.get("effective_notes") if not rows: return P.append(f"\n### {title}\n") P.append("| model | " + " | ".join(cols) + " | unit | notes |") P.append("|---|" + "---|" * (len(cols) + 2)) for k, d in rows.items(): P.append(f"| `{k}` | " + " | ".join(money(d.get(c)) for c in cols) + f" | {d.get('_unit','')} | {d.get('_note') or ''} |") P.append("## Text models (Flagship + legacy text)\n") for tier in ("standard", "batch", "flex", "fast"): table("Flagship models", ["input", "cached_input", "cache_write", "output"], f"{tier.capitalize()} — short context (≤272K)", tier=tier, ctx="short") table("Flagship models", ["input", "cached_input", "cache_write", "output"], f"{tier.capitalize()} — long context (>272K)", tier=tier, ctx="long") table("Cyber models", ["input", "cached_input", "cache_write", "output"], "Cyber / Daybreak models (standard)", ctx="short") P.append("\n`gpt-daybreak-blue-latest` → `gpt-5.6-sol`, `gpt-daybreak-red-latest` → `gpt-5.6-cyber` (aliases, priced as the underlying model).\n") table("GPT-Live sessions", ["session_duration"], "GPT-Live sessions (per minute, billed per second)") table("Realtime and audio generation models", ["audio_input", "audio_cached_input", "audio_output", "text_input", "text_cached_input", "text_output", "image_input", "image_cached_input"], "Realtime & audio models") table("Image generation models", ["image_input", "image_cached_input", "image_output", "text_input", "text_cached_input", "text_output"], "Image models — standard", tier="standard") table("Image generation models", ["image_input", "image_cached_input", "image_output", "text_input", "text_cached_input", "text_output"], "Image models — batch", tier="batch") table("Video generation models", ["video_output"], "Video (Sora 2) — standard, per second", tier="standard") table("Video generation models", ["video_output"], "Video (Sora 2) — batch, per second", tier="batch") table("Transcription models", ["audio_input", "text_output", "audio_duration"], "Transcription / translation models") table("Specialized models", ["input", "cached_input", "output"], "Specialized models — standard", tier="standard") table("Specialized models", ["input", "cached_input", "output"], "Specialized models — fast", tier="fast") table("Finetuning", ["fine_tuning_training", "fine_tuned_input", "fine_tuned_cached_input", "fine_tuned_output"], "Fine-tuning — standard (platform winding down; see deprecations)", tier="standard") table("Finetuning", ["fine_tuning_training", "fine_tuned_input", "fine_tuned_cached_input", "fine_tuned_output"], "Fine-tuning — batch inference", tier="batch") P.append("\n### Built-in tools\n") P.append("| tool | details | price | unit | full text |\n|---|---|---|---|---|") for p in prices: if p["model_or_service"].startswith("tool:"): P.append(f"| {p['model_or_service'][5:]} | {p['dimension']} | {money(p['price'])} | {p['unit']} | {p['effective_notes']} |") P.append("\n### Prices only on model pages (not in pricing.md)\n") P.append("| model | dimension | price | unit | notes |\n|---|---|---|---|---|") for p in prices: if p.get("group") == "model page": P.append(f"| `{p['model_or_service']}` | {p['dimension']} | {money(p['price'])} | {p['unit']} | {(p['effective_notes'] or '')[:160]} |") P.append("\n### Pricing rules (derived records `rule:*`)\n") P.append("| rule | dimension | value | unit | notes |\n|---|---|---|---|---|") for p in prices: if p["model_or_service"].startswith("rule:") or p["dimension"] in ("promotion", "billing_start"): P.append(f"| {p['model_or_service']} | {p['dimension']} | {p['price'] if p['price'] is not None else ''} | {p['unit']} | {p['effective_notes']} |") P.append("\n## Footnotes copied from the pricing page\n") for f in footnotes: P.append(f"- {f}") P.append("\n## Caveats\n- Prices are documentation values as of 2026-09-18; the pricing page states promotional pricing for GPT-5.6 Sol through at least 2026-11-21.\n" "- Fine-tuning: platform is winding down (no new orgs since 2026-05-07; job creation ends 2027-01-06); inference on fine-tuned models continues until the base model is deprecated.\n" "- Realtime/Live sessions, image tokens and video seconds are billed on different units — check `unit` in every record.\n") (ROOT / "docs/openai").mkdir(parents=True, exist_ok=True) (ROOT / "docs/openai/pricing.md").write_text("\n".join(P) + "\n") # ---------------- deprecations doc D = [] D.append("# OpenAI — Deprecations & retirements (API Atlas)\n") D.append("**Status:** DOCUMENTED (deprecations page) + LIVE_VERIFIED cross-check (`shutdown_date` field of GET /v1/models, 2026-09-18). Machine-readable twin: `generated/fragments/deprecations/openai-deprecations.json`.\n") D.append("**Sources:** https://developers.openai.com/api/docs/deprecations · https://developers.openai.com/api/docs/changelog · https://api.openai.com/v1/models\n") D.append("**Last verified:** 2026-09-18\n") D.append("## Policy (notice periods)\n\n| Category | Minimum notice | Examples |\n|---|---|---|\n| Generally available models | ≥ 6 months | gpt-5, o3 |\n| Specialized variants | ≥ 3 months | `gpt-5.1-chat-latest`, `gpt-5.3-codex`, `o3-deep-research` |\n| Preview models | may be ~2 weeks | `computer-use-preview`, `gpt-4o-audio-preview` |\n\n" "*Deprecated* = retirement announced (immediately deprecated, always with a shutdown date). *Legacy* = no longer updated, will be deprecated later. *Sunset/shut down* = no longer accessible. Dedicated capacity may be negotiable after shutdown (sales).\n") D.append("## Upcoming (shutdown after 2026-09-18)\n") D.append("| shutdown | subject | kind | replacement | announced | section |\n|---|---|---|---|---|---|") for e in sorted([e for e in deps if e.get("shutdown_date") and e["shutdown_date"] > TODAY and e["phase"] != "live_api_field"], key=lambda e: e["shutdown_date"]): D.append(f"| {e['shutdown_date']} | `{e['subject']}` | {e['kind']} | {e['replacement'] or ''} | {e['announced'] or ''} | {e['section']} |") D.append("\n## Feature / platform milestones\n") D.append("| date | subject | update |\n|---|---|---|") for e in [e for e in deps if e["kind"] == "feature"]: D.append(f"| {e['milestone_date']} | {e['subject']} | {e['update']} |") D.append("\n## Past (shutdown on or before 2026-09-18)\n") D.append("| shutdown | subject | kind | replacement | announced | section |\n|---|---|---|---|---|---|") for e in sorted([e for e in deps if e.get("shutdown_date") and e["shutdown_date"] <= TODAY and e["phase"] != "live_api_field"], key=lambda e: e["shutdown_date"], reverse=True): D.append(f"| {e['shutdown_date']} | `{e['subject']}` | {e['kind']} | {e['replacement'] or ''} | {e['announced'] or ''} | {e['section']} |") D.append("\n## Live cross-check: `shutdown_date` in GET /v1/models (2026-09-18)\n") D.append("Every listed model carries a `shutdown_date` (null or ISO date). Rows below compare it with the deprecations page.\n") D.append("| id | live shutdown_date | consistency with docs |\n|---|---|---|") for e in [e for e in deps if e["phase"] == "live_api_field"]: D.append(f"| `{e['primary_id']}` | {e['shutdown_date']} | {e['consistency']} |") still = [r for r in records if "STILL_LISTED_AFTER_DOCUMENTED_SHUTDOWN" in r["flags"]] D.append("\n## Notable findings\n") D.append(f"- **{len(still)} ids are documented as shut down but were still returned by GET /v1/models on 2026-09-18**: " + ", ".join(f"`{r['id']}`" for r in still) + ". Listing ≠ usable: treat as RETIRED unless a call succeeds.") only_live = [e for e in deps if e["phase"] == "live_api_field" and e["consistency"] == "NOT on deprecations page"] D.append(f"- **{len(only_live)} live shutdown dates are not on the deprecations page**: " + ", ".join(f"`{e['primary_id']}` ({e['shutdown_date']})" for e in only_live) + ".") diff = [e for e in deps if e["phase"] == "live_api_field" and e["consistency"].startswith("differs")] if diff: D.append("- Dates that differ between live field and docs: " + ", ".join(f"`{e['primary_id']}` live {e['shutdown_date']} vs {e['consistency']}" for e in diff) + ".") D.append("- Assistants API shut down 2026-08-26 (Responses + Conversations replace it). Videos API and Sora 2 shut down 2026-09-24 with no replacement. Realtime beta and DALL·E shut down 2026-05-12. `v1/prompts`, Evals API and Agent Builder shut down 2026-11-30.\n" "- Self-serve fine-tuning: closed to new orgs since 2026-05-07; last job creation 2027-01-06; inference persists until the base model is deprecated.\n") (ROOT / "docs/openai/deprecations.md").write_text("\n".join(D) + "\n") if __name__ == "__main__": main()