From 5434a4758572221a2b25be9727ed5705e3f9b1c2 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 09:26:08 +0900 Subject: [PATCH] feat(workflow): monitor all published Databricks models, not a fixed pair The monitor hard-coded MONITORED = {deepseek-v4-flash, deepseek-v4-pro}, so new page models were never detected and column reordering could silently mis-map rates. - Parse every "Standard Pay Per Token" table on both pricing pages (open FMS + proprietary FMS); map numeric columns by header text (Input / Output / Cache read / Cache write), which differs per table - Compare against every mapped registry entry and report UPDATED / PROMO_SKIPPED / PROMO_ON_PAGE / REVIEW / RATES_AVAILABLE / NOT_IN_REGISTRY / UNMAPPED_PAGE_MODEL / MISSING_FROM_PAGE - Refresh only rate fields in place; metadata (context windows, capabilities, deprecation dates) is preserved - Cache fields: page value wins; when the page shows n/a, entries bill cache at the input rate; custom conventions (gemini 0.1x reads) kept - Dash/n-a cells are placeholders, not row qualifiers (gpt-oss / bge / gemma rows were silently dropped before) - Skip long-context tier rows and image/audio token sub-rows - Workflow: PR body now embeds the full monitor report from /tmp/dbx_monitor_pr_body.md Verified live: on the current branch registry the monitor reports 10 cache-field UPDATEDs matching the values upstream already stores, 8 retired models flagged MISSING_FROM_PAGE, 2 PROMO_SKIPPED (gemini 2.5), and NOT_IN_REGISTRY for every new model awaiting #39714. --- .../workflows/monitor_databricks_pricing.yml | 12 +- scripts/monitor_databricks_pricing.py | 501 ++++++++++++++---- 2 files changed, 420 insertions(+), 93 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 2e66c7fb10a..0aefd778b40 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -1,4 +1,4 @@ -name: Monitor Databricks Pricing (deepseek/glm/kimi) +name: Monitor Databricks Pricing on: schedule: @@ -45,10 +45,16 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" git add model_prices_and_context_window.json litellm/model_prices_and_context_window_backup.json - git commit -m "chore(model_prices): refresh Databricks Foundation Model Serving rates - automated monitor detected changed DBU rates on the Databricks pricing page; updated databricks-deepseek-v4-* entries." + git commit -m "chore(model_prices): refresh Databricks Foundation Model Serving rates - automated monitor detected changed DBU rates on the Databricks pricing pages; refreshed the mapped databricks/* entries." git push "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + { + cat /tmp/dbx_monitor_pr_body.md + echo + echo "---" + echo "Auto-generated by the Databricks pricing monitor. \`PROMO_SKIPPED\`/\`REVIEW\`/\`MISSING_FROM_PAGE\` lines need human attention; \`UPDATED\` lines were applied automatically." + } > /tmp/dbx_pr_body_final.md gh pr create --repo "${{ github.repository }}" \ --base litellm_internal_staging \ --head "$BRANCH" \ --title "chore(model_prices): refresh Databricks Foundation Model Serving rates" \ - --body "Automated daily check of the Databricks Foundation Model Serving pricing page detected rate changes. Updated model_prices_and_context_window.json and packaged backup for monitored models." + --body-file /tmp/dbx_pr_body_final.md diff --git a/scripts/monitor_databricks_pricing.py b/scripts/monitor_databricks_pricing.py index 0c04e37e759..8a68ddcb48f 100644 --- a/scripts/monitor_databricks_pricing.py +++ b/scripts/monitor_databricks_pricing.py @@ -1,18 +1,32 @@ #!/usr/bin/env python3 -"""Monitor Databricks Foundation Model Serving pricing pages and update LiteLLM's +"""Monitor Databricks Foundation Model Serving pricing and update LiteLLM's model_prices_and_context_window.json + packaged backup when rates change. Triggered daily by .github/workflows/monitor_databricks_pricing.yml. -Behavior: -- Fetches the two official Databricks pricing pages (HTML, JS-rendered price - table). Parses the embedded price data rows via regex extraction of the - DBU table (works with the current page markup; fails loudly otherwise). -- Applies the LiteLLM convention: USD = DBU * 0.07 per token. -- Updates entries for the monitored model set (see MONITORED below). -- If any monitored rate changed, writes BOTH files, prints a diff summary and - exits 0 (so the workflow can create the PR). If nothing changed, exits 0 - with "NO_CHANGE" marker so the workflow skips PR creation. +Behavior (registry-wide, not a fixed model list): +- Fetches both official pricing pages (open FMS + proprietary FMS) and parses + every "Standard Pay Per Token" DBU table. Numeric columns are mapped by + header text (Input / Output / Cache read / Cache write), so column + reordering is safe. Priority/Batch/Provisioned tables are ignored. +- Compares page rates against every mapped registry entry and reports: + UPDATED - entry stores the published list rate and the page + value moved: rate fields refreshed in place. + PROMO_SKIPPED - entry stores the promotional rate (page list x 0.8); + left untouched, page value reported for a human. + PROMO_ON_PAGE - page displays a promotional price for an entry + storing the list rate; left untouched for a human. + REVIEW - stored rate matches neither pattern; manual check. + RATES_AVAILABLE - page publishes rates for an entry that has none. + NOT_IN_REGISTRY - page lists a mapped model the registry lacks. + UNMAPPED_PAGE_MODEL- page lists a model with no mapping (new model?). + MISSING_FROM_PAGE - entry priced from these pages is no longer listed. +- Long-context tier rows and per-modality sub-rows (image/audio tokens) are + skipped; only rate fields are touched, all other entry metadata is kept. +- If any entry was UPDATED, both JSON files are rewritten and a report is + written to /tmp/dbx_monitor_pr_body.md for the workflow's PR body. + Otherwise "NO_CHANGE" is printed so the workflow skips PR creation. + Exit code is always 0. """ import json @@ -20,37 +34,125 @@ import re import sys import urllib.request from pathlib import Path +from typing import Dict, List, Optional, Tuple REPO_ROOT = Path(__file__).resolve().parents[1] MAIN_MAP = REPO_ROOT / "model_prices_and_context_window.json" BACKUP_MAP = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json" +PR_BODY_PATH = Path("/tmp/dbx_monitor_pr_body.md") DBU_TO_USD = 0.07 +REL_TOL = 1.5e-3 # page rates carry 3-decimal rounding noise +PROMO_RATIO = 0.8 +PROMO_TOL = 0.02 -# Databricks Foundation Model Serving page (open models, incl. DeepSeek V4) FMS_PAGE = "https://www.databricks.com/product/pricing/foundation-model-serving" -# Proprietary page (GPT/Claude/Gemini) — fetched but not used for the monitored -# monitorset; kept for future expansion. -PROPRIETARY_PAGE = "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" +PROPRIETARY_PAGE = ( + "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving" +) +PAGES = (FMS_PAGE, PROPRIETARY_PAGE) -# model_map key -> (name pattern in the DBU table row, ) -# name pattern is the model label as it appears on the pricing page table. -MONITORED = { - "databricks/databricks-deepseek-v4-flash-0731": "Deepseek V4 Flash (0731)", - "databricks/databricks-deepseek-v4-pro-0813": "Deepseek V4 Pro (0813)", +# Header names (lowercased) of the numeric columns we consume. "cache write +# (1hr)" tiers are not modeled by LiteLLM and are dropped. +NUMERIC_HEADERS = ("input", "output", "cache read", "cache write") +DROPPED_HEADERS = ("cache write (1hr)",) + +# Rowspan continuation rows on the proprietary page carry tier/modality +# qualifiers in the first cell; they never start a new model row. +QUALIFIER_LABELS = ("long context", "image tokens", "audio tokens", "in-geo", "global") +USABLE_QUALIFIERS = ("", "short context", "text tokens") + +# Page label (markup stripped, lowercased) -> registry keys under +# "databricks/". Labels missing here are reported as UNMAPPED_PAGE_MODEL +# (new-model signal); keys missing from the registry are reported as +# NOT_IN_REGISTRY. +LABEL_TO_KEYS: Dict[str, List[str]] = { + # Open Foundation Model Serving page + "kimi k3": ["databricks-kimi-k3"], + "glm-5.2, 5.3": ["databricks-glm-5-2", "databricks-glm-5-3"], + "deepseek v4 pro": ["databricks-deepseek-v4-pro-0813"], + "inkling": ["databricks-inkling"], + "glm-5.3 flash": ["databricks-glm-5-3-flash"], + "deepseek v4 flash": ["databricks-deepseek-v4-flash-0731"], + "qwen 3.5 122b": ["databricks-qwen35-122b-a10b"], + "llama 4 maverick": ["databricks-llama-4-maverick"], + "llama 3.3 70b": ["databricks-meta-llama-3-3-70b-instruct"], + "qwen 3 80b instruct": ["databricks-qwen3-next-80b-a3b-instruct"], + "gpt-oss-120b": ["databricks-gpt-oss-120b"], + "gemma 3 12b": ["databricks-gemma-3-12b"], + "llama 3.1 8b": ["databricks-meta-llama-3-1-8b-instruct"], + "gpt-oss-20b": ["databricks-gpt-oss-20b"], + "gte": ["databricks-gte-large-en"], + "bge large": ["databricks-bge-large-en"], + "qwen 3 0.6b embedding": ["databricks-qwen3-embedding-0-6b"], + # Proprietary Foundation Model Serving page + "gpt-5.6 sol": ["databricks-gpt-5-6-sol"], + "gpt-5.6 terra": ["databricks-gpt-5-6-terra"], + "gpt-5.6 luna": ["databricks-gpt-5-6-luna"], + "gpt-5.5": ["databricks-gpt-5-5"], + "gpt-5.4 pro, 5.5 pro": ["databricks-gpt-5-5-pro"], + "gpt-5.4": ["databricks-gpt-5-4"], + "gpt-5.4 mini": ["databricks-gpt-5-4-mini"], + "gpt-5.4 nano": ["databricks-gpt-5-4-nano"], + "gpt-5.2 codex, 5.3 codex": ["databricks-gpt-5-2-codex", "databricks-gpt-5-3-codex"], + "gpt-5.2": ["databricks-gpt-5-2"], + "gpt-5, 5.1": ["databricks-gpt-5", "databricks-gpt-5-1"], + "gpt-5.1 codex max": ["databricks-gpt-5-1-codex-max"], + "gpt-5.1 codex mini": ["databricks-gpt-5-1-codex-mini"], + "gpt-5 mini": ["databricks-gpt-5-mini"], + "gpt-5 nano": ["databricks-gpt-5-nano"], + "claude fable 5.1": ["databricks-claude-fable-5-1"], + "claude fable 5": ["databricks-claude-fable-5"], + "claude opus 4.5, 4.6, 4.7, 4.8, 5": [ + "databricks-claude-opus-4-5", + "databricks-claude-opus-4-6", + "databricks-claude-opus-4-7", + "databricks-claude-opus-4-8", + "databricks-claude-opus-5", + ], + "claude opus 4, 4.1": ["databricks-claude-opus-4", "databricks-claude-opus-4-1"], + "claude sonnet 5": ["databricks-claude-sonnet-5"], + "claude sonnet 4.5, 4.6": [ + "databricks-claude-sonnet-4-5", + "databricks-claude-sonnet-4-6", + ], + "claude sonnet 4": ["databricks-claude-sonnet-4"], + "claude haiku 4.5": ["databricks-claude-haiku-4-5"], + "gemini 3.0 pro, 3.1 pro": ["databricks-gemini-3-1-pro"], + "gemini 2.5 pro": ["databricks-gemini-2-5-pro"], + "gemini 3.7 flash, 3.8 flash": [ + "databricks-gemini-3-7-flash", + "databricks-gemini-3-8-flash", + ], + "gemini 3.6 flash": ["databricks-gemini-3-6-flash"], + "gemini 3.5 flash": ["databricks-gemini-3-5-flash"], + "gemini 3.0 flash": ["databricks-gemini-3-flash"], + "gemini 2.5 flash": ["databricks-gemini-2-5-flash"], + "gemini 3.5 flash lite": ["databricks-gemini-3-5-flash-lite"], + "gemini 3.1 flash lite": ["databricks-gemini-3-1-flash-lite"], + "gemini 3 pro image": ["databricks-gemini-3-pro-image"], + "gemini 3.1 flash image": ["databricks-gemini-3-1-flash-image"], + "grok 4.6": ["databricks-grok-4-6"], } -# Context windows / output caps from Databricks Foundation Model APIs limits doc -# (kept in sync with what we know; only rates are refreshed by this script). -MODEL_FIXTURE = { - "databricks/databricks-deepseek-v4-flash-0731": { - "max_input_tokens": 200000, - "max_output_tokens": 10000, - }, - "databricks/databricks-deepseek-v4-pro-0813": { - "max_input_tokens": 200000, - "max_output_tokens": 4000, - }, +# Page labels with no text-token registry mapping today: image-generation +# models bill per-image/vendor pass-through, and Kimi K2.7 is not in the +# supported-models docs. Remove from here once entries exist. +IGNORED_LABELS = ( + "kimi k2.7", + "gpt image 1", + "gpt image 1 mini", + "gpt image 1.5", + "gpt image 2", + "gemini 3.1 flash lite image", +) + +# Rate fields an entry stores for a page-published column. +FIELD_BY_HEADER = { + "input": ("input_cost_per_token", "input_dbu_cost_per_token"), + "output": ("output_cost_per_token", "output_dbu_cost_per_token"), + "cache read": ("cache_read_input_token_cost", None), + "cache write": ("cache_creation_input_token_cost", None), } @@ -59,93 +161,312 @@ def fetch(url: str, max_bytes: int = 5_000_000) -> str: req = urllib.request.Request(url, headers={"User-Agent": "litellm-price-monitor/1.0"}) with urllib.request.urlopen(req, timeout=60) as resp: if resp.status != 200: - raise RuntimeError(f"HTTP {resp.status} fetching {url}") + raise RuntimeError("HTTP {} fetching {}".format(resp.status, url)) return resp.read(max_bytes + 1).decode("utf-8", errors="replace") -def parse_dbu_table(html: str) -> dict[str, tuple[float, float]]: - rows: dict[str, tuple[float, float]] = {} - for tr in re.findall(r"(.*?)", html, flags=re.S): - cells = re.findall(r"]*>(.*?)", tr, flags=re.S) - if not cells: +def _cell_text(html: str) -> str: + return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", html)).strip() + + +def _parse_number(cell: str) -> Optional[float]: + txt = cell.replace(",", "").strip() + if re.fullmatch(r"\d+(?:\.\d+)?", txt): + return float(txt) + return None + + +def _is_placeholder(cell: str) -> bool: + """True for dash/n/a cells meaning 'not published' - neither number nor label.""" + return cell.strip() in ("-", "—", "–", "n/a", "N/A", "na") + + +def parse_standard_pp_token_tables( + html: str, +) -> Dict[str, List[Tuple[str, Tuple[Optional[float], ...], Tuple[str, ...]]]]: + """Extract rows of every "Standard Pay Per Token" table. + + Returns label -> list of (qualifier, numbers, numeric_cols), where numbers + align with that table's numeric header order. + """ + parsed: Dict[str, List[Tuple[str, Tuple[Optional[float], ...], Tuple[str, ...]]]] = {} + for tm in re.finditer(r"]*>(.*?)", html, re.S): + table = tm.group(1) + head = re.search(r"(.*?)", table, re.S) + if head is None: continue - label = re.sub(r"<[^>]+>", "", cells[0]).strip() - nums = [] - for c in cells[1:]: - txt = re.sub(r"<[^>]+>", "", c).strip() - if re.fullmatch(r"\d+(?:\.\d+)?", txt): - nums.append(float(txt)) - if label and len(nums) >= 2: - rows[label] = (nums[0], nums[1]) - - results: dict[str, tuple[float, float]] = {} - for label in MONITORED.values(): - if label not in rows: - raise RuntimeError(f"Could not locate pricing row for '{label}' on {FMS_PAGE}") - results[label] = rows[label] - return results - - -def build_entry(model_key: str, input_dbu: float, output_dbu: float) -> dict: - fx = MODEL_FIXTURE[model_key] - return { - "input_cost_per_token": input_dbu / 1_000_000 * DBU_TO_USD, - "input_dbu_cost_per_token": input_dbu, - "litellm_provider": "databricks", - "max_input_tokens": fx["max_input_tokens"], - "max_output_tokens": fx["max_output_tokens"], - "max_tokens": fx["max_output_tokens"], - "metadata": { - "notes": ( - f"Pricing derived from Databricks Foundation Model Serving DBU rates " - f"({input_dbu:g} in / {output_dbu:g} out DBU per 1M tokens × ${DBU_TO_USD:.2f}/DBU " - f"= ${input_dbu * DBU_TO_USD:.2f}/${output_dbu * DBU_TO_USD:.2f} per 1M). " - f"Auto-refreshed daily by monitor_databricks_pricing workflow." + header_texts = [ + _cell_text(th) for th in re.findall(r"]*>(.*?)", head.group(1), re.S) + ] + if not any("Standard Pay Per Token" in h for h in header_texts): + continue + numeric_cols = tuple( + h.lower() + for h in header_texts + if h.lower() in NUMERIC_HEADERS and h.lower() not in DROPPED_HEADERS + ) + for rm in re.finditer(r"(.*?)", table, re.S): + cells = [ + _cell_text(c) + for c in re.findall(r"]*>(.*?)", rm.group(1), re.S) + ] + if not cells: + continue + label = re.sub(r"[\*⌖]+", "", cells[0]).strip().lower() + if not label or label == "model" or label in header_texts: + continue + if label in QUALIFIER_LABELS: + continue # rowspan continuation row (tier / modality rate) + qualifier = "" + numbers: List[Optional[float]] = [] + for cell in cells[1:]: + if _is_placeholder(cell): + numbers.append(None) + continue + num = _parse_number(cell) + if num is not None: + numbers.append(num) + elif cell and not qualifier: + qualifier = cell.lower() + if len(numbers) < 1 or qualifier not in USABLE_QUALIFIERS: + continue # label-only rows / single-metric or modality rows + numbers.extend([None] * (len(numeric_cols) - len(numbers))) + parsed.setdefault(label, []).append( + (qualifier, tuple(numbers[: len(numeric_cols)]), numeric_cols) ) - }, - "mode": "chat", - "output_cost_per_token": output_dbu / 1_000_000 * DBU_TO_USD, - "output_dbu_cost_per_token": output_dbu, - "source": FMS_PAGE, - "supports_function_calling": True, - "supports_reasoning": True, - "supports_tool_choice": True, - } + return parsed + + +def dbu_to_usd(dbu: Optional[float]) -> Optional[float]: + if dbu is None: + return None + return dbu / 1_000_000 * DBU_TO_USD + + +def _approx(a: Optional[float], b: Optional[float]) -> bool: + if a is None or b is None: + return a is None and b is None + return abs(a - b) <= max(abs(b) * REL_TOL, 1e-12) + + +def classify(entry: Dict, page_in: float, page_out: Optional[float]) -> str: + """How the entry's stored rates relate to the page row.""" + stored_in = entry.get("input_cost_per_token") + if stored_in is None: + return "unpriced" + usd_in = dbu_to_usd(page_in) + usd_out = dbu_to_usd(page_out) if page_out is not None else None + if usd_in is None: + return "no-input-column" + if _approx(stored_in, usd_in) and ( + usd_out is None or _approx(entry.get("output_cost_per_token"), usd_out) + ): + return "list" + if usd_in and abs(stored_in / usd_in - PROMO_RATIO) <= PROMO_TOL: + stored_out = entry.get("output_cost_per_token") + if ( + usd_out is None + or stored_out is None + or abs(stored_out / usd_out - PROMO_RATIO) <= PROMO_TOL + ): + return "promo" + if usd_in and abs(usd_in / stored_in - PROMO_RATIO) <= PROMO_TOL: + return "page-promo" + return "mismatch" + + +def _refresh_cache_fields( + entry: Dict, header_values: Dict[str, Optional[float]] +) -> Tuple[bool, List[str]]: + """Sync cache rates with the page; keep n/a conventions tracking input.""" + notes: List[str] = [] + changed = False + input_usd = entry.get("input_cost_per_token") + for header, field in ( + ("cache read", "cache_read_input_token_cost"), + ("cache write", "cache_creation_input_token_cost"), + ): + page_dbu = header_values.get(header) + if page_dbu is not None: + target = dbu_to_usd(page_dbu) + note = "{} DBU {}".format(field, page_dbu) + elif input_usd and ( + entry.get(field) is None or _approx(entry.get(field), input_usd) + ): + # not on the page: bill cache at input; custom conventions (gemini 0.1x) fall through + target = input_usd + note = "{}=input (not published)".format(field) + else: + continue + if target is not None and not _approx(entry.get(field), target): + notes.append("{}: {} -> {}".format(note, entry.get(field), target)) + entry[field] = target + changed = True + return changed, notes + + +def update_entry( + entry: Dict, + key: str, + numbers: Tuple[Optional[float], ...], + numeric_cols: Tuple[str, ...], + page_url: str, +) -> Tuple[bool, str]: + """Apply page rates to one registry entry. Returns (changed, report line).""" + header_values = dict(zip(numeric_cols, numbers)) + page_in = header_values.get("input") + page_out = header_values.get("output") + if page_in is None: + return False, "REVIEW {}: page row has no input column".format(key) + status = classify(entry, page_in, page_out) + if status == "promo": + return False, ( + "PROMO_SKIPPED {}: entry stores the promotional rate; page list input={} output={}".format( + key, page_in, page_out + ) + ) + if status == "page-promo": + return False, ( + "PROMO_ON_PAGE {}: page shows promotional pricing (input={}); " + "entry keeps list rate {}".format( + key, page_in, entry.get("input_cost_per_token") + ) + ) + if status == "mismatch": + stored = entry.get("input_cost_per_token") + ratio = stored / dbu_to_usd(page_in) if stored and page_in else 0 + return False, ( + "REVIEW {}: stored input={} vs page list input={} DBU (ratio {:.3f}) " + "- manual check".format(key, stored, page_in, ratio) + ) + changed = False + detail: List[str] = [] + for header in ("input", "output"): + page_dbu = header_values.get(header) + if page_dbu is None: + continue # e.g. embeddings: output not published - preserve stored + usd_field, dbu_field = FIELD_BY_HEADER[header] + target_usd = dbu_to_usd(page_dbu) + if not _approx(entry.get(usd_field), target_usd): + detail.append("{} {}->{}".format(header, entry.get(usd_field), target_usd)) + entry[usd_field] = target_usd + changed = True + if dbu_field is not None: + target_dbu = page_dbu / 1_000_000 + if not _approx(entry.get(dbu_field), target_dbu): + entry[dbu_field] = target_dbu + changed = True + cache_changed, cache_notes = _refresh_cache_fields(entry, header_values) + detail.extend(cache_notes) + changed = changed or cache_changed + if changed and entry.get("source") != page_url: + entry["source"] = page_url + if changed: + return True, "UPDATED {}: {}".format(key, "; ".join(detail)) + return False, "" def main() -> int: - html = fetch(FMS_PAGE) - rates = parse_dbu_table(html) + pages = [(url, fetch(url)) for url in PAGES] with MAIN_MAP.open() as f: main_data = json.load(f) with BACKUP_MAP.open() as f: backup_data = json.load(f) + tracked_keys: set = set() changed = False - for model_key, label in MONITORED.items(): - in_dbu, out_dbu = rates[label] - entry = build_entry(model_key, in_dbu, out_dbu) - old = main_data.get(model_key) - if old != entry: - main_data[model_key] = entry - backup_data[model_key] = entry - changed = True - sys.stdout.write( - f"CHANGED {model_key}: {old and old.get('input_cost_per_token')} -> {entry['input_cost_per_token']}\n" + report_lines: List[str] = [] + + for page_url, html in pages: + tables = parse_standard_pp_token_tables(html) + for label, rows in sorted(tables.items()): + if label in IGNORED_LABELS: + continue + keys = LABEL_TO_KEYS.get(label) + qualifier, numbers, numeric_cols = rows[0] + if keys is None: + report_lines.append( + "UNMAPPED_PAGE_MODEL: '{}' on {} lists input={} output={} DBU/1M " + "- add a mapping or a registry entry".format( + label, Path(page_url).name, numbers[0], numbers[1] + ) + ) + continue + for key in keys: + full_key = "databricks/" + key + tracked_keys.add(full_key) + entry = main_data.get(full_key) + if entry is None: + report_lines.append( + "NOT_IN_REGISTRY {}: '{}' on {} lists rates ({} DBU in) " + "but the registry has no entry".format( + full_key, label, Path(page_url).name, numbers[0] + ) + ) + continue + if not entry.get("input_cost_per_token"): + report_lines.append( + "RATES_AVAILABLE {}: '{}' now publishes rates ({} DBU in / {} out) " + "- entry currently unpriced".format( + full_key, label, numbers[0], numbers[1] + ) + ) + continue + was_changed, line = update_entry( + entry, full_key, numbers, numeric_cols, page_url + ) + changed = changed or was_changed + if line: + report_lines.append(line) + + # Entries priced from these pages that vanished from them: retirement signal. + for full_key, entry in main_data.items(): + if not full_key.startswith("databricks/") or full_key in tracked_keys: + continue + if entry.get("input_cost_per_token") and entry.get("source") in PAGES: + report_lines.append( + "MISSING_FROM_PAGE {}: priced entry no longer on the pricing pages " + "- check for retirement".format(full_key) ) if not changed: sys.stdout.write("NO_CHANGE\n") + for line in report_lines: + sys.stdout.write(line + "\n") return 0 + for full_key, entry in main_data.items(): + if full_key.startswith("databricks/") and full_key in backup_data: + backup_data[full_key] = entry + with MAIN_MAP.open("w") as f: json.dump(main_data, f, indent=4) f.write("\n") with BACKUP_MAP.open("w") as f: json.dump(backup_data, f, indent=4) f.write("\n") - sys.stdout.write("WROTE updated model map and backup\n") + + body = [ + "Automated daily check of the Databricks Foundation Model Serving pricing pages", + "([open](https://www.databricks.com/product/pricing/foundation-model-serving),", + "[proprietary](https://www.databricks.com/product/pricing/proprietary-foundation-model-serving))", + "detected published-rate changes. Rate fields refreshed in place; metadata untouched.", + "", + "## Monitor report", + "", + "```", + ] + body.extend(report_lines) + body.append("```") + PR_BODY_PATH.write_text("\n".join(body) + "\n") + + sys.stdout.write("CHANGED\n") + for line in report_lines: + sys.stdout.write(line + "\n") + sys.stdout.write( + "WROTE updated model map, backup and PR body to {}\n".format(PR_BODY_PATH) + ) return 0