From 8aa1076ad897fb36f5c9607a4f52f042fdd6578c Mon Sep 17 00:00:00 2001 From: leecoder Date: Mon, 31 Aug 2026 18:26:54 +0900 Subject: [PATCH 01/12] feat(workflow): daily monitor of Databricks Foundation Model Serving pricing Adds a GitHub Action (daily 02:00 UTC + manual dispatch) that: - Scrapes the official DBU rates for DeepSeek V4 Flash (0731) / V4 Pro (0813) from https://www.databricks.com/product/pricing/foundation-model-serving - Derives per-token USD at $0.07/DBU and updates both model_prices_and_context_window.json and the packaged backup - Opens a PR (base litellm_internal_staging) only when a rate actually changed Runs on the leecoder fork (no repo guard) so the fork stays current and can feed an upstream PR on demand. --- .../workflows/monitor_databricks_pricing.py | 151 ++++++++++++++++++ .../workflows/monitor_databricks_pricing.yml | 56 +++++++ 2 files changed, 207 insertions(+) create mode 100644 .github/workflows/monitor_databricks_pricing.py create mode 100644 .github/workflows/monitor_databricks_pricing.yml diff --git a/.github/workflows/monitor_databricks_pricing.py b/.github/workflows/monitor_databricks_pricing.py new file mode 100644 index 00000000000..de645c3bd05 --- /dev/null +++ b/.github/workflows/monitor_databricks_pricing.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Monitor Databricks Foundation Model Serving pricing pages 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. +""" + +import json +import re +import sys +import urllib.request +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MAIN_MAP = REPO_ROOT / "model_prices_and_context_window.json" +BACKUP_MAP = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json" + +DBU_TO_USD = 0.07 + +# 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" + +# 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)", +} + +# 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, + }, +} + + +def fetch(url: str, max_bytes: int = 5_000_000) -> str: + """Fetch page HTML; returns text. Raises on non-200.""" + 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}") + 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: + 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." + ) + }, + "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, + } + + +def main() -> int: + html = fetch(FMS_PAGE) + rates = parse_dbu_table(html) + + with MAIN_MAP.open() as f: + main_data = json.load(f) + with BACKUP_MAP.open() as f: + backup_data = json.load(f) + + 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 + print(f"CHANGED {model_key}: {old and old.get('input_cost_per_token')} -> {entry['input_cost_per_token']}") + + if not changed: + print("NO_CHANGE") + return 0 + + 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") + print("WROTE updated model map and backup") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml new file mode 100644 index 00000000000..ea2eaeefb2d --- /dev/null +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -0,0 +1,56 @@ +name: Monitor Databricks Pricing (deepseek/glm/kimi) + +on: + schedule: + - cron: "0 2 * * *" # daily 02:00 UTC + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + monitor-db-pricing: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run monitor + id: monitor + run: | + python .github/workflows/monitor_databricks_pricing.py | tee /tmp/monitor_out.txt + if grep -q '^NO_CHANGE' /tmp/monitor_out.txt; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Open PR if changed + if: steps.monitor.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + BRANCH="monitor-dbx-pricing-$(date +'%Y-%m-%d')" + git config user.name "github-actions[bot]" + 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 daily monitor detected changed DBU rates on +https://www.databricks.com/product/pricing/foundation-model-serving +Updated entries: databricks/databricks-deepseek-v4-*" + git push origin "$BRANCH" + 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." || true \ No newline at end of file From 6cf693e12ec50283ee1ac27135601e2d60e85f6b Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 1 Sep 2026 09:03:28 +0900 Subject: [PATCH 02/12] fix(workflow): propagate scraper and PR-creation failures, pin actions to SHAs - Add set -o pipefail so a scraper exception fails the job instead of being misread as a price change - Drop the || true suppression so a failed PR creation fails the run - Pin actions/checkout and actions/setup-python to immutable commit SHAs (repo convention, addresses Greptile/veria-ai review comments) --- .github/workflows/monitor_databricks_pricing.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index ea2eaeefb2d..6d1180bda3a 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -13,19 +13,20 @@ jobs: monitor-db-pricing: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: "3.12" - name: Run monitor - id: monitor + shell: bash run: | + set -o pipefail python .github/workflows/monitor_databricks_pricing.py | tee /tmp/monitor_out.txt if grep -q '^NO_CHANGE' /tmp/monitor_out.txt; then echo "changed=false" >> "$GITHUB_OUTPUT" @@ -53,4 +54,4 @@ Updated entries: databricks/databricks-deepseek-v4-*" --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." || true \ No newline at end of file + --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." \ No newline at end of file From 937ed9642501bcc915e9639b4b0236501d6b0ea4 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 1 Sep 2026 10:41:45 +0900 Subject: [PATCH 03/12] style(workflow): trailing newline --- .github/workflows/monitor_databricks_pricing.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 6d1180bda3a..de35b58e676 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -54,4 +54,4 @@ Updated entries: databricks/databricks-deepseek-v4-*" --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." \ No newline at end of file + --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." From cf4ca7ab1343fe968328c176db7a57431a52e144 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 1 Sep 2026 11:17:31 +0900 Subject: [PATCH 04/12] fix(workflow): authenticate git push with GITHUB_TOKEN (persist-credentials: false) actions/checkout runs with persist-credentials: false, so the previous 'git push origin' would fail with auth error. Push over HTTPS with an x-access-token URL using GITHUB_TOKEN. Addresses Greptile review: 'authenticated Git push configuration'. --- .github/workflows/monitor_databricks_pricing.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index de35b58e676..501acc503be 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -38,6 +38,7 @@ jobs: if: steps.monitor.outputs.changed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN_FOR_PUSH: ${{ secrets.GITHUB_TOKEN }} run: | BRANCH="monitor-dbx-pricing-$(date +'%Y-%m-%d')" git config user.name "github-actions[bot]" @@ -49,7 +50,7 @@ jobs: Automated daily monitor detected changed DBU rates on https://www.databricks.com/product/pricing/foundation-model-serving Updated entries: databricks/databricks-deepseek-v4-*" - git push origin "$BRANCH" + git push "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" gh pr create --repo "${{ github.repository }}" \ --base litellm_internal_staging \ --head "$BRANCH" \ From 906cc70573ad0c76452ab0eeb5d9367bcdd19ee8 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 1 Sep 2026 11:49:59 +0900 Subject: [PATCH 05/12] fix(workflow): move scraper out of workflows dir, fix yml YAML block scalar - .github/scripts/assert_workflow_dir_hygiene requires only .yml files in .github/workflows/; relocate monitor_databricks_pricing.py to scripts/ - Fix YAML ScannerError by turning the multi-line git commit -m block into a single line (YAML block scalar containment issue) - update the run step path to scripts/monitor_databricks_pricing.py --- .github/workflows/monitor_databricks_pricing.yml | 8 ++------ .../workflows => scripts}/monitor_databricks_pricing.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) rename {.github/workflows => scripts}/monitor_databricks_pricing.py (99%) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 501acc503be..2e66c7fb10a 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -27,7 +27,7 @@ jobs: shell: bash run: | set -o pipefail - python .github/workflows/monitor_databricks_pricing.py | tee /tmp/monitor_out.txt + python scripts/monitor_databricks_pricing.py | tee /tmp/monitor_out.txt if grep -q '^NO_CHANGE' /tmp/monitor_out.txt; then echo "changed=false" >> "$GITHUB_OUTPUT" else @@ -45,11 +45,7 @@ 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 daily monitor detected changed DBU rates on -https://www.databricks.com/product/pricing/foundation-model-serving -Updated entries: databricks/databricks-deepseek-v4-*" + 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 push "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" gh pr create --repo "${{ github.repository }}" \ --base litellm_internal_staging \ diff --git a/.github/workflows/monitor_databricks_pricing.py b/scripts/monitor_databricks_pricing.py similarity index 99% rename from .github/workflows/monitor_databricks_pricing.py rename to scripts/monitor_databricks_pricing.py index de645c3bd05..18d6f31b955 100644 --- a/.github/workflows/monitor_databricks_pricing.py +++ b/scripts/monitor_databricks_pricing.py @@ -21,7 +21,7 @@ import sys import urllib.request from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[2] +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" From ca0375a6ee11b47b87f26d5edfef823fce85d83c Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 1 Sep 2026 12:19:19 +0900 Subject: [PATCH 06/12] style(workflow): replace print with sys.stdout.write, ruff format (T201 gate) scripts/monitor_databricks_pricing.py violated T201 (print) which the LiteLLM Linting workflow gates; switch to sys.stdout.write and run ruff format so lint checks pass locally before CI re-run. --- scripts/monitor_databricks_pricing.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/monitor_databricks_pricing.py b/scripts/monitor_databricks_pricing.py index 18d6f31b955..0c04e37e759 100644 --- a/scripts/monitor_databricks_pricing.py +++ b/scripts/monitor_databricks_pricing.py @@ -99,7 +99,7 @@ def build_entry(model_key: str, input_dbu: float, output_dbu: float) -> dict: "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"= ${input_dbu * DBU_TO_USD:.2f}/${output_dbu * DBU_TO_USD:.2f} per 1M). " f"Auto-refreshed daily by monitor_databricks_pricing workflow." ) }, @@ -131,10 +131,12 @@ def main() -> int: main_data[model_key] = entry backup_data[model_key] = entry changed = True - print(f"CHANGED {model_key}: {old and old.get('input_cost_per_token')} -> {entry['input_cost_per_token']}") + sys.stdout.write( + f"CHANGED {model_key}: {old and old.get('input_cost_per_token')} -> {entry['input_cost_per_token']}\n" + ) if not changed: - print("NO_CHANGE") + sys.stdout.write("NO_CHANGE\n") return 0 with MAIN_MAP.open("w") as f: @@ -143,9 +145,9 @@ def main() -> int: with BACKUP_MAP.open("w") as f: json.dump(backup_data, f, indent=4) f.write("\n") - print("WROTE updated model map and backup") + sys.stdout.write("WROTE updated model map and backup\n") return 0 if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) From 5434a4758572221a2b25be9727ed5705e3f9b1c2 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 09:26:08 +0900 Subject: [PATCH 07/12] 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 From acc4a41f218b8528516a3ae04a736e7bfb433949 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 12:22:01 +0900 Subject: [PATCH 08/12] ci(workflow): run monitor on the fork and open PRs against upstream The monitor now executes on the leecoder/litellm fork: - checkout pins the upstream repo and its litellm_internal_staging base, so the monitor always audits upstream's registry, not the fork's - schedule guard skips upstream's own checkout (only the fork runs it); manual workflow_dispatch still works on upstream - changed rates push to a dated branch on the fork and open the PR against BerriAI/litellm with head leecoder: - PR identity: leecoder / leecoder@aol.com - auth uses the DBX_MONITOR_TOKEN secret (leecoder PAT with repo scopes); the default GITHUB_TOKEN cannot create PRs across forks - permissions trimmed to contents: write --- .../workflows/monitor_databricks_pricing.yml | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 0aefd778b40..3b2c19da76b 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -1,5 +1,8 @@ name: Monitor Databricks Pricing +# Runs on the leecoder/litellm fork on a daily schedule. When the official +# Databricks pricing pages drift from the registry, the monitor opens a PR +# against the upstream repo (BerriAI/litellm) from a fork branch. on: schedule: - cron: "0 2 * * *" # daily 02:00 UTC @@ -7,16 +10,25 @@ on: permissions: contents: write - pull-requests: write + +env: + FORK_REPO: leecoder/litellm + UPSTREAM_REPO: BerriAI/litellm + UPSTREAM_BASE: litellm_internal_staging jobs: monitor-db-pricing: + # Upstream runs the same checkout on schedule; skip there so only the + # fork drives the monitor. Manual dispatch still works on upstream. + if: github.repository == 'leecoder/litellm' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false fetch-depth: 0 + repository: ${{ env.UPSTREAM_REPO }} + ref: ${{ env.UPSTREAM_BASE }} - name: Set up Python uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 @@ -24,6 +36,7 @@ jobs: python-version: "3.12" - name: Run monitor + id: monitor shell: bash run: | set -o pipefail @@ -34,27 +47,28 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Open PR if changed + - name: Open PR on upstream if changed if: steps.monitor.outputs.changed == 'true' env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_TOKEN_FOR_PUSH: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.DBX_MONITOR_TOKEN }} # leecoder PAT with repo + workflow scopes + GITHUB_TOKEN_FOR_PUSH: ${{ secrets.DBX_MONITOR_TOKEN }} run: | BRANCH="monitor-dbx-pricing-$(date +'%Y-%m-%d')" - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config user.name "leecoder" + git config user.email "leecoder@aol.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 pages; refreshed the mapped databricks/* entries." - git push "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${GITHUB_REPOSITORY}.git" "$BRANCH" + git remote add fork "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${FORK_REPO}.git" + git push fork "HEAD:refs/heads/$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." + echo "Auto-generated by the Databricks pricing monitor running on the ${FORK_REPO} fork. \`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" \ + gh pr create --repo "$UPSTREAM_REPO" \ + --base "$UPSTREAM_BASE" \ + --head "leecoder:$BRANCH" \ --title "chore(model_prices): refresh Databricks Foundation Model Serving rates" \ --body-file /tmp/dbx_pr_body_final.md From d7317b9d7d8d5c2b8ae92edb4dd01336a6bcb6f1 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 12:51:43 +0900 Subject: [PATCH 09/12] ci(workflow): fork-only schedule guard and script bootstrap Two fixes for the fork-run setup: - the monitor audits upstream's registry (checkout pins BerriAI/litellm@litellm_internal_staging), where the script does not exist until #38950 lands - bootstrap it from the fork branch when missing - cron would fire on both repos once merged; guard the job to the fork so only leecoder/litellm opens PRs and upstream never races --- .github/workflows/monitor_databricks_pricing.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 3b2c19da76b..340aba1727b 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -15,12 +15,13 @@ env: FORK_REPO: leecoder/litellm UPSTREAM_REPO: BerriAI/litellm UPSTREAM_BASE: litellm_internal_staging + # carries the monitor script until PR #38950 lands upstream + SCRIPT_REF: feat/dbx-pricing-monitor-clean jobs: monitor-db-pricing: - # Upstream runs the same checkout on schedule; skip there so only the - # fork drives the monitor. Manual dispatch still works on upstream. - if: github.repository == 'leecoder/litellm' || github.event_name == 'workflow_dispatch' + # cron would fire on both repos after merge; only the fork may open PRs, or they'd race + if: github.repository == 'leecoder/litellm' runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -30,6 +31,14 @@ jobs: repository: ${{ env.UPSTREAM_REPO }} ref: ${{ env.UPSTREAM_BASE }} + - name: Bootstrap monitor script until #38950 lands upstream + run: | + if [ ! -f scripts/monitor_databricks_pricing.py ]; then + mkdir -p scripts + curl -fsSL "https://raw.githubusercontent.com/${FORK_REPO}/${SCRIPT_REF}/scripts/monitor_databricks_pricing.py" \ + -o scripts/monitor_databricks_pricing.py + fi + - name: Set up Python uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: From a0f5dda44e271ffff6717d0c66081a344040d2a9 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 14:27:15 +0900 Subject: [PATCH 10/12] fix(workflow): map gemini-3-pro and sonnet-4-1 to their page rows The registry prices both models from rows shared with siblings, but the mapping only listed one key per row, so the monitor flagged them MISSING_FROM_PAGE every run: - 'Gemini 3.0 Pro, 3.1 Pro' also prices databricks-gemini-3-pro - 'Claude Sonnet 4' also prices databricks-claude-sonnet-4-1 Verified against upstream/staging values: all four entries match the page list rates (35.714/214.286/3.571 and 42.857/214.286/4.286 DBU). --- scripts/monitor_databricks_pricing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/monitor_databricks_pricing.py b/scripts/monitor_databricks_pricing.py index 8a68ddcb48f..1a06dcd618f 100644 --- a/scripts/monitor_databricks_pricing.py +++ b/scripts/monitor_databricks_pricing.py @@ -116,9 +116,9 @@ LABEL_TO_KEYS: Dict[str, List[str]] = { "databricks-claude-sonnet-4-5", "databricks-claude-sonnet-4-6", ], - "claude sonnet 4": ["databricks-claude-sonnet-4"], + "claude sonnet 4": ["databricks-claude-sonnet-4", "databricks-claude-sonnet-4-1"], "claude haiku 4.5": ["databricks-claude-haiku-4-5"], - "gemini 3.0 pro, 3.1 pro": ["databricks-gemini-3-1-pro"], + "gemini 3.0 pro, 3.1 pro": ["databricks-gemini-3-1-pro", "databricks-gemini-3-pro"], "gemini 2.5 pro": ["databricks-gemini-2-5-pro"], "gemini 3.7 flash, 3.8 flash": [ "databricks-gemini-3-7-flash", From 9bb64b112054727985cd83051db12d43f99dfec9 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 14:57:05 +0900 Subject: [PATCH 11/12] ci(workflow): drop the PAT - use the built-in token per repo The monitor used to run on the fork and open PRs against upstream, which structurally requires a cross-repo PAT. Two changes remove it: - PRs are created in whichever repo the workflow runs in (GITHUB_REPOSITORY), so the built-in github.token covers both push and pr create - same pattern as upstream's auto_update_price_and_context_window workflow - the job guard flips: upstream owns the merged schedule (github.repository == BerriAI/litellm), the fork stays dispatch-only, so the two never race on the same base The script bootstrap from the fork branch stays until #38950 lands upstream. DBX_MONITOR_TOKEN secret is no longer referenced. --- .../workflows/monitor_databricks_pricing.yml | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 340aba1727b..01ed6112c6c 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -1,8 +1,10 @@ name: Monitor Databricks Pricing -# Runs on the leecoder/litellm fork on a daily schedule. When the official -# Databricks pricing pages drift from the registry, the monitor opens a PR -# against the upstream repo (BerriAI/litellm) from a fork branch. +# Daily monitor of the Databricks Foundation Model Serving pricing pages. +# Before PR #38950 merges this workflow only lives on the leecoder/litellm +# fork (manual dispatch); after the merge it runs on BerriAI/litellm's cron. +# PRs are always created in the repository the workflow runs in, so the +# built-in GITHUB_TOKEN is sufficient - no PAT or secret required. on: schedule: - cron: "0 2 * * *" # daily 02:00 UTC @@ -10,18 +12,19 @@ on: permissions: contents: write + pull-requests: write env: - FORK_REPO: leecoder/litellm UPSTREAM_REPO: BerriAI/litellm UPSTREAM_BASE: litellm_internal_staging - # carries the monitor script until PR #38950 lands upstream + # branch that carries the monitor script until PR #38950 lands upstream SCRIPT_REF: feat/dbx-pricing-monitor-clean jobs: monitor-db-pricing: - # cron would fire on both repos after merge; only the fork may open PRs, or they'd race - if: github.repository == 'leecoder/litellm' + # once merged the cron fires on both repos; upstream owns the schedule, + # the fork stays dispatch-only so the two never race on the same base + if: github.repository == 'BerriAI/litellm' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 @@ -35,7 +38,7 @@ jobs: run: | if [ ! -f scripts/monitor_databricks_pricing.py ]; then mkdir -p scripts - curl -fsSL "https://raw.githubusercontent.com/${FORK_REPO}/${SCRIPT_REF}/scripts/monitor_databricks_pricing.py" \ + curl -fsSL "https://raw.githubusercontent.com/leecoder/litellm/${SCRIPT_REF}/scripts/monitor_databricks_pricing.py" \ -o scripts/monitor_databricks_pricing.py fi @@ -56,28 +59,27 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi - - name: Open PR on upstream if changed + - name: Open PR if changed if: steps.monitor.outputs.changed == 'true' env: - GH_TOKEN: ${{ secrets.DBX_MONITOR_TOKEN }} # leecoder PAT with repo + workflow scopes - GITHUB_TOKEN_FOR_PUSH: ${{ secrets.DBX_MONITOR_TOKEN }} + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN_FOR_PUSH: ${{ github.token }} run: | BRANCH="monitor-dbx-pricing-$(date +'%Y-%m-%d')" - git config user.name "leecoder" - git config user.email "leecoder@aol.com" + git config user.name "github-actions[bot]" + 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 pages; refreshed the mapped databricks/* entries." - git remote add fork "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${FORK_REPO}.git" - git push fork "HEAD:refs/heads/$BRANCH" + git push "https://x-access-token:${GITHUB_TOKEN_FOR_PUSH}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$BRANCH" { cat /tmp/dbx_monitor_pr_body.md echo echo "---" - echo "Auto-generated by the Databricks pricing monitor running on the ${FORK_REPO} fork. \`PROMO_SKIPPED\`/\`REVIEW\`/\`MISSING_FROM_PAGE\` lines need human attention; \`UPDATED\` lines were applied automatically." + 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 "$UPSTREAM_REPO" \ + gh pr create --repo "$GITHUB_REPOSITORY" \ --base "$UPSTREAM_BASE" \ - --head "leecoder:$BRANCH" \ + --head "$BRANCH" \ --title "chore(model_prices): refresh Databricks Foundation Model Serving rates" \ --body-file /tmp/dbx_pr_body_final.md From 56d7dff551c45d5712d64eba6dcf77109342cd64 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 8 Sep 2026 15:34:22 +0900 Subject: [PATCH 12/12] ci(workflow): accept repository_dispatch and let manual triggers run on the fork - repository_dispatch (event_type: dbx-monitor) provides an external trigger for price-drift alerting to kick off a check - the job guard previously allowed only workflow_dispatch on the fork, which would have skipped repository_dispatch runs; now only the schedule event is pinned to upstream so cron never races, while both manual trigger types run on either repo --- .github/workflows/monitor_databricks_pricing.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/monitor_databricks_pricing.yml b/.github/workflows/monitor_databricks_pricing.yml index 01ed6112c6c..884d8169435 100644 --- a/.github/workflows/monitor_databricks_pricing.yml +++ b/.github/workflows/monitor_databricks_pricing.yml @@ -9,6 +9,9 @@ on: schedule: - cron: "0 2 * * *" # daily 02:00 UTC workflow_dispatch: + # external trigger: POST /repos///dispatches {"event_type":"dbx-monitor"} + repository_dispatch: + types: [dbx-monitor] permissions: contents: write @@ -22,9 +25,9 @@ env: jobs: monitor-db-pricing: - # once merged the cron fires on both repos; upstream owns the schedule, - # the fork stays dispatch-only so the two never race on the same base - if: github.repository == 'BerriAI/litellm' || github.event_name == 'workflow_dispatch' + # the merged cron fires on both repos; upstream owns the schedule so the + # two never race on the same base. manual triggers run on either repo. + if: github.event_name != 'schedule' || github.repository == 'BerriAI/litellm' runs-on: ubuntu-latest steps: - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0