From 8aa1076ad897fb36f5c9607a4f52f042fdd6578c Mon Sep 17 00:00:00 2001 From: leecoder Date: Mon, 31 Aug 2026 18:26:54 +0900 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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 From affdef5b1edc8f8c2c8fbfaef389e6677b30efc8 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 22 Sep 2026 13:24:16 +0900 Subject: [PATCH 13/14] feat(macos-usage): add gateway quota menu bar app --- apps/macos-usage/.gitignore | 3 + apps/macos-usage/DESIGN.md | 84 +++++ apps/macos-usage/Package.swift | 9 + .../LiteLLMUsageMenuBar/GatewayClient.swift | 143 ++++++++ .../LiteLLMUsageMenuBar/KeychainStore.swift | 80 +++++ .../LiteLLMUsageMenuBarApp.swift | 34 ++ .../Sources/LiteLLMUsageMenuBar/Models.swift | 176 ++++++++++ .../LiteLLMUsageMenuBar/UsageStore.swift | 311 ++++++++++++++++++ .../Sources/LiteLLMUsageMenuBar/Views.swift | 198 +++++++++++ apps/macos-usage/assets/litellm_logo.jpg | Bin 0 -> 9222 bytes apps/macos-usage/scripts/build-app.sh | 56 ++++ .../scripts/fill-logo-background.swift | 104 ++++++ 12 files changed, 1198 insertions(+) create mode 100644 apps/macos-usage/.gitignore create mode 100644 apps/macos-usage/DESIGN.md create mode 100644 apps/macos-usage/Package.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift create mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift create mode 100644 apps/macos-usage/assets/litellm_logo.jpg create mode 100755 apps/macos-usage/scripts/build-app.sh create mode 100644 apps/macos-usage/scripts/fill-logo-background.swift diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore new file mode 100644 index 00000000000..564c4754447 --- /dev/null +++ b/apps/macos-usage/.gitignore @@ -0,0 +1,3 @@ +.build/ +LiteLLMUsage.app/ +LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md new file mode 100644 index 00000000000..62b0d981133 --- /dev/null +++ b/apps/macos-usage/DESIGN.md @@ -0,0 +1,84 @@ +# LiteLLM Usage Menu Bar Design System + +## 0. Research Log +- Embedded refs: operational menu bar surface, using the taste-skill restraint rather than a marketing layout +- Lazyweb: skipped, this is a native macOS utility with no web surface +- Imagen drafts: skipped, a system utility has no image-led visual contract +- Apple platform reference: SwiftUI `MenuBarExtra` window style, available on macOS 13+ + +## 1. Atmosphere & Identity + +A quiet glanceable utility. The signature is a single signal, the current personal quota percentage, supported by a small amount of operational detail when the menu opens + +## 2. Color + +| Role | Token | Value | Usage | +|------|------|------|------| +| Surface | surface | system background | Menu popover | +| Text | text | primary | Main labels | +| Muted | muted | secondary | Supporting facts | +| Accent | accent | system blue | Refresh and settings actions | +| Success | success | system green | Healthy quota state | +| Warning | warning | system orange | High quota state | +| Error | error | system red | Failure state | + +## 3. Typography + +Primary: macOS system font. Mono: system monospaced font for amounts and percentages. Body text is at least 13pt + +## 4. Spacing & Layout + +Spacing derives from a 4pt base. The popover uses a 320pt width, 16pt outer padding, 12pt section gaps, and 8pt inline gaps + +## 5. Components + +### Menu bar status +- Structure: compact text label with percentage +- Variants: loading, healthy, warning, error, no quota +- States: default, disabled while loading +- Accessibility: label includes the full percentage and state + +### App icon +- Motif: the official LiteLLM logo aligned with the menu bar's 🚅 identity +- Source: the official square `assets/litellm_logo.jpg` mark with its sky-blue background extended across the canvas, generated into a standard macOS `.icns` bundle resource + +### Usage popover +- Structure: title, quota signal, usage facts, status line, action row +- Variants: configured, needs key, loading, success, failure, unavailable quota +- States: default, loading, error +- Accessibility: buttons have labels and visible text; color is never the only status signal + +### Settings form + - Structure: auto-refresh picker, gateway URL field, username field, secure password field, explanatory label, save action +- Variants: empty, configured +- States: focused, saving, saved; successful save dismisses the settings window +- Accessibility: secure text entry and explicit save action + +### Settings window + - Size: 380pt wide by 360pt high so the refresh picker, gateway URL, fields, explanation, and save action remain visible +- Presentation: a single independent window opened explicitly by the usage popover's settings action, never a sheet attached to the menu bar +- Focus: opening settings activates the app for keyboard input; closing the popover must not close settings +- Storage: username and password share one Keychain item; session tokens remain in memory for the running app + +### Usage scope picker +- Placement: top of the usage popover, above the quota signal +- Options: team total plus the authenticated user's named keys +- Fallback: a key without its own budget uses the team's monthly budget as its limit + +### Auto refresh +- Options: off, adaptive, 1 minute, 5 minutes, 15 minutes, 30 minutes, or 1 hour +- Default: adaptive +- Adaptive behavior: uses current utilization, recent spend rate, estimated time to limit, and reset time +- Behavior: refreshes the selected team or key budget while the menu bar app is running + +## 6. Motion & Interaction + +No decorative animation. Native popover transitions and button press feedback are retained. Refresh is explicit and periodic refresh does not steal focus + +## 7. Depth & Surface + +Use native macOS popover materials and tonal hierarchy. Do not add custom shadows or gradients + +## 8. Accessibility Constraints & Accepted Debt + +Percentage text remains readable without color. The app does not expose a custom chart or notification surface in this version diff --git a/apps/macos-usage/Package.swift b/apps/macos-usage/Package.swift new file mode 100644 index 00000000000..feb3f12f3c0 --- /dev/null +++ b/apps/macos-usage/Package.swift @@ -0,0 +1,9 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "LiteLLMUsageMenuBar", + platforms: [.macOS(.v13)], + products: [.executable(name: "LiteLLMUsageMenuBar", targets: ["LiteLLMUsageMenuBar"])], + targets: [.executableTarget(name: "LiteLLMUsageMenuBar")] +) diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift new file mode 100644 index 00000000000..67efc11ffce --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift @@ -0,0 +1,143 @@ +import Foundation + +struct GatewayClient: Sendable { + let baseURL: URL + + static let defaultBaseURL = URL(string: "https://api.litellm.ai")! + + static func baseURL(from input: String) -> URL? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + ["http", "https"].contains(scheme), + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil else { return nil } + while components.path.count > 1, components.path.hasSuffix("/") { + components.path.removeLast() + } + return components.url + } + + func login(username: String, password: String) async throws -> AuthSession { + let url = baseURL.appending(path: "/v2/login") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + guard httpResponse.statusCode == 200 else { + if httpResponse.statusCode == 401 { throw GatewayError.unauthorized } + throw GatewayError.server(httpResponse.statusCode) + } + let payload = try JSONDecoder().decode(LoginResponse.self, from: data) + guard let token = payload.token, let session = AuthSession(token: token) else { + throw GatewayError.invalidLoginResponse + } + return session + } + + func fetchUserInfo(apiKey: String) async throws -> UserInfoResponse { + let url = baseURL.appending(path: "/v2/user/info") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + + return try decoder.decode(UserInfoResponse.self, from: data) + } + + func fetchTeamInfo(apiKey: String, teamID: String) async throws -> TeamBudget { + var components = URLComponents(url: baseURL.appending(path: "/team/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "team_id", value: teamID)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(TeamInfoResponse.self, from: data).teamInfo + } + + func fetchKeyList(apiKey: String) async throws -> [String] { + let url = baseURL.appending(path: "/key/list") + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyListResponse.self, from: data).keys + } + + func fetchKeyInfo(apiKey: String, key: String) async throws -> KeyBudget { + var components = URLComponents(url: baseURL.appending(path: "/key/info"), resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "key", value: key)] + guard let url = components?.url else { throw GatewayError.invalidResponse } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } + switch httpResponse.statusCode { + case 200...299: break + case 401: throw GatewayError.unauthorized + case 403: throw GatewayError.forbidden + case 404: throw GatewayError.notFound + default: throw GatewayError.server(httpResponse.statusCode) + } + return try decoder.decode(KeyInfoResponse.self, from: data).info + } + + private var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let value = try decoder.singleValueContainer().decode(String.self) + if let date = ISO8601DateFormatter().date(from: value) { return date } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX" + guard let date = formatter.date(from: value) else { throw GatewayError.invalidResponse } + return date + } + return decoder + } + +} + +private struct LoginResponse: Decodable { + let token: String? +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift new file mode 100644 index 00000000000..aa976a19408 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift @@ -0,0 +1,80 @@ +import Foundation +import Security +import LocalAuthentication + +struct KeychainStore: Sendable { + private let service = "com.litellm.usage-menubar" + private let credentialsAccount = "ui-credentials" + + func readCredentials() throws -> (username: String, password: String)? { + guard let value = try read(account: credentialsAccount) else { return nil } + return try JSONDecoder().decode(StoredCredentials.self, from: Data(value.utf8)).tuple + } + + func saveCredentials(username: String, password: String) throws { + let value = try JSONEncoder().encode(StoredCredentials(username: username, password: password)) + try save(String(data: value, encoding: .utf8) ?? "", account: credentialsAccount) + } + + private func read(account: String) throws -> String? { + let context = LAContext() + context.interactionNotAllowed = true + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainError(status) + } + return String(data: data, encoding: .utf8) + } + + private func save(_ value: String, account: String) throws { + let data = Data(value.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + var item = query + item[kSecValueData as String] = data + let addStatus = SecItemAdd(item as CFDictionary, nil) + if addStatus == errSecSuccess { return } + guard addStatus == errSecDuplicateItem else { + throw KeychainError(addStatus) + } + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + if updateStatus != errSecSuccess { + throw KeychainError(updateStatus) + } + } +} + +private struct StoredCredentials: Codable { + let username: String + let password: String + + var tuple: (username: String, password: String) { + (username, password) + } +} + +struct KeychainError: LocalizedError, Equatable { + let status: OSStatus + + init(_ status: OSStatus) { self.status = status } + + var errorDescription: String? { + if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled { + return "저장된 로그인 정보에 접근할 수 없습니다. 설정에서 다시 저장하세요. Keychain 승인은 저장할 때만 요청합니다" + } + return "Keychain 처리에 실패했습니다 (\(status))" + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift new file mode 100644 index 00000000000..cc0d2400c43 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift @@ -0,0 +1,34 @@ +import SwiftUI + +@main +struct LiteLLMUsageMenuBarApp: App { + @StateObject private var store = UsageStore() + + var body: some Scene { + MenuBarExtra { + UsagePopover().environmentObject(store) + } label: { + MenuBarLabel().environmentObject(store) + } + .menuBarExtraStyle(.window) + + Window("로그인 설정", id: "login-settings") { + SettingsView().environmentObject(store) + } + .windowResizability(.contentSize) + .defaultPosition(.center) + } +} + +private struct MenuBarLabel: View { + @EnvironmentObject private var store: UsageStore + + var body: some View { + switch store.state { + case let .ready(snapshot): Text("🚅 \(snapshot.percentage.formatted(.number.precision(.fractionLength(0))))%") + case .loading: Text("🚅 ...") + case .needsKey: Text("🚅 --") + case .unavailable, .failure: Text("🚅 !") + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift new file mode 100644 index 00000000000..a2501916e3a --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift @@ -0,0 +1,176 @@ +import Foundation + +struct UserInfoResponse: Decodable, Sendable { + let userID: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teams: [String] + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teams + } +} + +struct TeamInfoResponse: Decodable, Sendable { + let teamInfo: TeamBudget + + enum CodingKeys: String, CodingKey { + case teamInfo = "team_info" + } +} + +struct TeamBudget: Decodable, Sendable { + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + + enum CodingKeys: String, CodingKey { + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + } +} + +struct KeyListResponse: Decodable, Sendable { + let keys: [String] +} + +struct KeyInfoResponse: Decodable, Sendable { + let info: KeyBudget + + enum CodingKeys: String, CodingKey { + case info + } +} + +struct KeyBudget: Decodable, Sendable { + let keyAlias: String? + let keyName: String + let spend: Double + let maxBudget: Double? + let budgetDuration: String? + let budgetResetAt: Date? + let teamID: String? + + enum CodingKeys: String, CodingKey { + case keyAlias = "key_alias" + case keyName = "key_name" + case spend + case maxBudget = "max_budget" + case budgetDuration = "budget_duration" + case budgetResetAt = "budget_reset_at" + case teamID = "team_id" + } +} + +struct GatewayKey: Identifiable, Equatable, Sendable { + let value: String + let keyName: String + let alias: String? + + var id: String { keyName } + var displayName: String { alias ?? keyName } +} + +struct AuthSession: Sendable { + let token: String + let apiKey: String + let expiresAt: Date + + init?(token: String) { + let segments: [Substring] = token.split(separator: ".") + guard segments.count == 3 else { return nil } + var encoded = String(segments[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) + guard let data = Data(base64Encoded: encoded), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let apiKey = payload["key"] as? String, + let exp = payload["exp"] as? TimeInterval else { return nil } + self.token = token + self.apiKey = apiKey + self.expiresAt = Date(timeIntervalSince1970: exp) + } +} + +enum UsageState: Equatable, Sendable { + case needsKey + case loading + case ready(UsageSnapshot) + case unavailable(String) + case failure(String) +} + +enum AutoRefreshInterval: Int, CaseIterable, Identifiable, Sendable { + case off = 0 + case adaptive = -1 + case oneMinute = 60 + case fiveMinutes = 300 + case fifteenMinutes = 900 + case thirtyMinutes = 1800 + case oneHour = 3600 + + var id: Self { self } + + var fixedSeconds: TimeInterval? { + switch self { + case .off, .adaptive: nil + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + TimeInterval(rawValue) + } + } + + var title: String { + switch self { + case .off: "끔" + case .adaptive: "적응형" + case .oneMinute: "1분" + case .fiveMinutes: "5분" + case .fifteenMinutes: "15분" + case .thirtyMinutes: "30분" + case .oneHour: "1시간" + } + } +} + +struct UsageSnapshot: Equatable, Sendable { + let spend: Double + let limit: Double + let percentage: Double + let budgetDuration: String + let resetAt: Date? +} + +enum GatewayError: LocalizedError { + case invalidResponse + case unauthorized + case forbidden + case notFound + case server(Int) + case noUserBudget + case notMonthlyBudget(String) + case invalidLoginResponse + case invalidGatewayURL + + var errorDescription: String? { + switch self { + case .invalidResponse: "게이트웨이 응답을 읽을 수 없습니다" + case .unauthorized: "로그인 정보가 올바르지 않거나 세션이 만료되었습니다" + case .forbidden: "개인 사용량 조회가 거부되었습니다 (HTTP 403)" + case .notFound: "사용자를 찾을 수 없습니다" + case let .server(code): "게이트웨이 오류 (HTTP \(code))" + case .noUserBudget: "개인 예산 한도가 설정되지 않았습니다" + case let .notMonthlyBudget(duration): "개인 예산이 월 단위가 아닙니다 (\(duration))" + case .invalidLoginResponse: "로그인 응답을 읽을 수 없습니다" + case .invalidGatewayURL: "올바른 게이트웨이 URL을 입력하세요 (예: https://gateway.example.com)" + } + } +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift new file mode 100644 index 00000000000..40eb7e8e3d0 --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift @@ -0,0 +1,311 @@ +import Foundation +import Combine + +@MainActor +final class UsageStore: ObservableObject { + @Published private(set) var state: UsageState = .needsKey + @Published private(set) var lastUpdated: Date? + @Published private(set) var availableKeys: [GatewayKey] = [] + @Published private(set) var selectedKeyName: String? + @Published private(set) var keyListMessage: String? + @Published private(set) var isLoadingKeys = false + @Published private(set) var autoRefreshInterval: AutoRefreshInterval + private let keychain: KeychainStore + private var client: GatewayClient + private(set) var gatewayURL: URL + private var credentials: (username: String, password: String)? + private var session: AuthSession? + private var isRefreshing = false + private var refreshTimer: Timer? + private var lastSpendSample: SpendSample? + private var smoothedSpendPerDay: Double? + private let selectedKeyDefaultsKey = "selected-key-name" + private let refreshIntervalDefaultsKey = "auto-refresh-interval" + private let gatewayURLDefaultsKey = "gateway-url" + + init(keychain: KeychainStore = KeychainStore(), client: GatewayClient? = nil) { + let configuredURL = client?.baseURL ?? Self.loadGatewayURL() + self.keychain = keychain + self.client = client ?? GatewayClient(baseURL: configuredURL) + gatewayURL = configuredURL + selectedKeyName = UserDefaults.standard.string(forKey: selectedKeyDefaultsKey) + let storedInterval = UserDefaults.standard.object(forKey: refreshIntervalDefaultsKey) as? Int + autoRefreshInterval = AutoRefreshInterval(rawValue: storedInterval ?? AutoRefreshInterval.adaptive.rawValue) ?? .adaptive + do { + credentials = try keychain.readCredentials() + state = hasCredentials ? .loading : .needsKey + } catch { + state = .failure(error.localizedDescription) + } + configureRefreshTimer() + Task { [weak self] in + await self?.loadKeys() + await self?.refresh() + } + } + + var hasCredentials: Bool { credentials != nil } + + var savedUsername: String { credentials?.username ?? "" } + var savedPassword: String { credentials?.password ?? "" } + + func saveSettings(gatewayURL input: String, username: String, password: String) throws { + guard let newGatewayURL = GatewayClient.baseURL(from: input) else { + throw GatewayError.invalidGatewayURL + } + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty, !password.isEmpty else { throw KeychainError(errSecParam) } + try keychain.saveCredentials(username: trimmedUsername, password: password) + UserDefaults.standard.set(newGatewayURL.absoluteString, forKey: gatewayURLDefaultsKey) + gatewayURL = newGatewayURL + client = GatewayClient(baseURL: newGatewayURL) + credentials = (trimmedUsername, password) + session = nil + availableKeys = [] + keyListMessage = nil + lastSpendSample = nil + smoothedSpendPerDay = nil + configureRefreshTimer() + Task { + while isRefreshing { + try? await Task.sleep(for: .milliseconds(50)) + } + await loadKeys() + await refresh() + } + } + + func setAutoRefreshInterval(_ interval: AutoRefreshInterval) { + guard autoRefreshInterval != interval else { return } + autoRefreshInterval = interval + UserDefaults.standard.set(interval.rawValue, forKey: refreshIntervalDefaultsKey) + configureRefreshTimer() + } + + func refresh() async { + guard !isRefreshing, credentials != nil else { return } + isRefreshing = true + defer { + isRefreshing = false + scheduleNextRefresh() + } + do { + let budget: TeamBudget + do { + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } catch GatewayError.unauthorized { + session = nil + let auth = try await authenticatedSession() + budget = try await fetchCurrentBudget(apiKey: auth.apiKey) + } + guard let limit = budget.maxBudget, limit > 0 else { throw GatewayError.noUserBudget } + guard let duration = budget.budgetDuration, isMonthly(duration) else { + throw GatewayError.notMonthlyBudget(budget.budgetDuration ?? "없음") + } + let now = Date() + updateSpendRate(spend: budget.spend, at: now) + state = .ready(UsageSnapshot( + spend: budget.spend, + limit: limit, + percentage: max(0, budget.spend / limit * 100), + budgetDuration: duration, + resetAt: budget.budgetResetAt + )) + lastUpdated = now + } catch let error as GatewayError { + state = .failure(error.localizedDescription) + } catch { + state = .failure("게이트웨이에 연결할 수 없습니다") + } + } + + func loadKeys() async { + guard !isLoadingKeys, hasCredentials, availableKeys.isEmpty else { return } + isLoadingKeys = true + keyListMessage = nil + defer { isLoadingKeys = false } + do { + let auth: AuthSession + do { + auth = try await authenticatedSession() + } catch GatewayError.unauthorized { + session = nil + auth = try await authenticatedSession() + } + let keys = try await fetchKeyOptions(apiKey: auth.apiKey) + availableKeys = keys + if let selectedKeyName, !keys.contains(where: { $0.keyName == selectedKeyName }) { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + } catch let error as GatewayError { + keyListMessage = error.localizedDescription + } catch { + keyListMessage = "키 목록을 불러올 수 없습니다" + } + } + + func selectKey(named keyName: String?) { + guard selectedKeyName != keyName else { return } + selectedKeyName = keyName + lastSpendSample = nil + smoothedSpendPerDay = nil + if let keyName { + UserDefaults.standard.set(keyName, forKey: selectedKeyDefaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + } + Task { await refresh() } + } + + private func configureRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + scheduleNextRefresh() + } + + private func scheduleNextRefresh() { + refreshTimer?.invalidate() + guard hasCredentials, let delay = nextRefreshDelay() else { + refreshTimer = nil + return + } + refreshTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + await self?.refresh() + } + } + } + + private func nextRefreshDelay() -> TimeInterval? { + switch autoRefreshInterval { + case .off: + return nil + case .adaptive: + guard case let .ready(snapshot) = state else { return 900 } + return adaptiveRefreshDelay(for: snapshot) + case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: + return autoRefreshInterval.fixedSeconds + } + } + + private func adaptiveRefreshDelay(for snapshot: UsageSnapshot) -> TimeInterval { + if snapshot.percentage >= 100 { + guard let resetAt = snapshot.resetAt else { return 86_400 } + return min(86_400, max(60, resetAt.timeIntervalSinceNow - 60)) + } + + let remaining = max(0, snapshot.limit - snapshot.spend) + if let smoothedSpendPerDay, smoothedSpendPerDay > 0 { + let secondsUntilLimit = remaining / smoothedSpendPerDay * 86_400 + if secondsUntilLimit <= 6 * 3_600 { return 60 } + if secondsUntilLimit <= 24 * 3_600 { return 300 } + if secondsUntilLimit <= 3 * 86_400 { return 900 } + } + + if snapshot.percentage >= 95 { return 300 } + if snapshot.percentage >= 80 { return 900 } + if snapshot.percentage >= 50 { return 3_600 } + return 14_400 + } + + private func updateSpendRate(spend: Double, at date: Date) { + if let lastSpendSample { + let elapsed = date.timeIntervalSince(lastSpendSample.date) + if elapsed > 0 { + let spendDelta = spend - lastSpendSample.spend + if spendDelta < 0 { + smoothedSpendPerDay = nil + } else { + let spendPerDay = spendDelta / elapsed * 86_400 + smoothedSpendPerDay = smoothedSpendPerDay.map { $0 * 0.7 + spendPerDay * 0.3 } ?? spendPerDay + } + } + } + lastSpendSample = SpendSample(spend: spend, date: date) + } + + private struct SpendSample { + let spend: Double + let date: Date + } + + private func authenticatedSession() async throws -> AuthSession { + if let session, session.expiresAt.timeIntervalSinceNow > 300 { return session } + guard let credentials else { throw GatewayError.unauthorized } + let refreshed = try await client.login(username: credentials.username, password: credentials.password) + session = refreshed + return refreshed + } + + private func fetchBudget(apiKey: String) async throws -> TeamBudget { + let userInfo = try await client.fetchUserInfo(apiKey: apiKey) + if userInfo.maxBudget != nil { + return TeamBudget( + spend: userInfo.spend, + maxBudget: userInfo.maxBudget, + budgetDuration: userInfo.budgetDuration, + budgetResetAt: userInfo.budgetResetAt + ) + } + guard let teamID = userInfo.teams.first else { throw GatewayError.noUserBudget } + return try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + } + + private func fetchCurrentBudget(apiKey: String) async throws -> TeamBudget { + guard let selectedKeyName else { + return try await fetchBudget(apiKey: apiKey) + } + + let keys = availableKeys.isEmpty ? try await fetchKeyOptions(apiKey: apiKey) : availableKeys + availableKeys = keys + guard let selectedKey = keys.first(where: { $0.keyName == selectedKeyName }) else { + self.selectedKeyName = nil + UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) + return try await fetchBudget(apiKey: apiKey) + } + + let keyInfo = try await client.fetchKeyInfo(apiKey: apiKey, key: selectedKey.value) + if let maxBudget = keyInfo.maxBudget { + return TeamBudget( + spend: keyInfo.spend, + maxBudget: maxBudget, + budgetDuration: keyInfo.budgetDuration, + budgetResetAt: keyInfo.budgetResetAt + ) + } + guard let teamID = keyInfo.teamID else { throw GatewayError.noUserBudget } + let teamBudget = try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) + return TeamBudget( + spend: keyInfo.spend, + maxBudget: teamBudget.maxBudget, + budgetDuration: teamBudget.budgetDuration, + budgetResetAt: teamBudget.budgetResetAt + ) + } + + private func fetchKeyOptions(apiKey: String) async throws -> [GatewayKey] { + let keys = try await client.fetchKeyList(apiKey: apiKey) + var options: [GatewayKey] = [] + for key in keys { + let info = try await client.fetchKeyInfo(apiKey: apiKey, key: key) + options.append(GatewayKey(value: key, keyName: info.keyName, alias: info.keyAlias)) + } + return options + } + + private func isMonthly(_ duration: String) -> Bool { + let normalized = duration.lowercased() + return ["1mo", "1month", "30d", "monthly"].contains(normalized) + } + + private static func loadGatewayURL() -> URL { + guard let savedURL = UserDefaults.standard.string(forKey: "gateway-url"), + let gatewayURL = GatewayClient.baseURL(from: savedURL) else { + return GatewayClient.defaultBaseURL + } + return gatewayURL + } + +} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift new file mode 100644 index 00000000000..99c7927a0bb --- /dev/null +++ b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift @@ -0,0 +1,198 @@ +import SwiftUI + +struct UsagePopover: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.openWindow) private var openWindow + private let teamSelection = "__team__" + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + scopePicker + content + Divider() + actions + } + .padding(16) + .frame(width: 320) + .task { + await store.loadKeys() + await store.refresh() + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("LiteLLM 사용량").font(.headline) + } + Spacer() + statusIcon + } + } + + private var scopePicker: some View { + VStack(alignment: .leading, spacing: 4) { + Picker("조회 대상", selection: Binding( + get: { store.selectedKeyName ?? teamSelection }, + set: { store.selectKey(named: $0 == teamSelection ? nil : $0) } + )) { + Text("팀 전체").tag(teamSelection) + ForEach(store.availableKeys) { key in + Text(key.displayName).tag(key.keyName) + } + } + .pickerStyle(.menu) + .disabled(!store.hasCredentials || store.isLoadingKeys) + if let keyListMessage = store.keyListMessage, !store.isLoadingKeys { + Text(keyListMessage).font(.caption).foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var content: some View { + switch store.state { + case .needsKey: + MessageView(title: "로그인 정보를 설정하세요", detail: "설정에서 게이트웨이 ID와 비밀번호를 입력하면 사용량을 조회합니다", symbol: "person.crop.circle.badge.key") + case .loading: + HStack(spacing: 8) { ProgressView(); Text("사용량을 확인하는 중...").foregroundStyle(.secondary) } + case let .ready(snapshot): + VStack(alignment: .leading, spacing: 10) { + Text(snapshot.percentage.formatted(.number.precision(.fractionLength(0))) + "%") + .font(.system(size: 38, weight: .bold, design: .rounded)) + .foregroundStyle(color(for: snapshot.percentage)) + ProgressView(value: min(snapshot.percentage, 100), total: 100) + .tint(color(for: snapshot.percentage)) + HStack { + Fact(label: "사용액", value: currency(snapshot.spend)) + Spacer() + Fact(label: "한도", value: currency(snapshot.limit)) + } + if let resetAt = snapshot.resetAt { + Text("다음 초기화 " + resetAt.formatted(date: .abbreviated, time: .shortened)) + .font(.caption).foregroundStyle(.secondary) + } + } + case let .unavailable(message), let .failure(message): + MessageView(title: "사용량을 표시할 수 없습니다", detail: message, symbol: "exclamationmark.triangle") + } + } + + private var actions: some View { + HStack { + Button("새로고침") { Task { await store.refresh() } } + .buttonStyle(.borderless) + .disabled(store.state == .loading || !store.hasCredentials) + Spacer() + Button("설정") { + openWindow(id: "login-settings") + NSApplication.shared.activate(ignoringOtherApps: true) + } + .buttonStyle(.borderless) + Button("종료") { NSApplication.shared.terminate(nil) } + .buttonStyle(.borderless) + } + .font(.callout) + } + + @ViewBuilder + private var statusIcon: some View { + switch store.state { + case .ready(let snapshot): Image(systemName: "circle.fill").foregroundStyle(color(for: snapshot.percentage)) + case .loading: ProgressView().controlSize(.small) + case .needsKey: Image(systemName: "key.fill").foregroundStyle(.secondary) + case .unavailable, .failure: Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) + } + } + + private func color(for percentage: Double) -> Color { + percentage >= 90 ? .red : percentage >= 75 ? .orange : .green + } + + private func currency(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } +} + +struct SettingsView: View { + @EnvironmentObject private var store: UsageStore + @Environment(\.dismiss) private var dismiss + @State private var gatewayURL = "" + @State private var username = "" + @State private var password = "" + @State private var message: String? + + var body: some View { + Form { + Section("사용량") { + Picker("자동 새로고침", selection: Binding( + get: { store.autoRefreshInterval }, + set: { store.setAutoRefreshInterval($0) } + )) { + ForEach(AutoRefreshInterval.allCases) { interval in + Text(interval.title).tag(interval) + } + } + Text("팝오버가 닫혀 있어도 메뉴바 사용량을 갱신합니다") + .font(.caption).foregroundStyle(.secondary) + } + Section("게이트웨이") { + TextField("서버 주소", text: $gatewayURL) + TextField("아이디", text: $username) + SecureField("비밀번호", text: $password) + Text("LiteLLM 프록시 서버 주소를 입력하세요") + .font(.caption).foregroundStyle(.secondary) + Text("로그인 정보는 macOS Keychain에 저장되며 세션은 앱 실행 중 메모리에만 유지됩니다") + .font(.caption).foregroundStyle(.secondary) + Button("저장") { + guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { + message = "아이디와 비밀번호를 모두 입력하세요" + return + } + do { + try store.saveSettings(gatewayURL: gatewayURL, username: username, password: password) + username = "" + password = "" + dismiss() + } catch { message = error.localizedDescription } + } + .disabled(gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || password.isEmpty) + if let message { Text(message).font(.caption).foregroundStyle(.secondary) } + } + } + .formStyle(.grouped) + .frame(width: 380, height: 360) + .padding() + .onAppear { + gatewayURL = store.gatewayURL.absoluteString + username = store.savedUsername + password = store.savedPassword + } + } +} + +private struct MessageView: View { + let title: String + let detail: String + let symbol: String + + var body: some View { + Label { + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.subheadline.weight(.semibold)) + Text(detail).font(.caption).foregroundStyle(.secondary) + } + } icon: { Image(systemName: symbol).foregroundStyle(.secondary) } + } +} + +private struct Fact: View { + let label: String + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(value).font(.system(.callout, design: .monospaced)) + } + } +} diff --git a/apps/macos-usage/assets/litellm_logo.jpg b/apps/macos-usage/assets/litellm_logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6fe96e2ed3567525bfc6487534fc1fb82a9fbf06 GIT binary patch literal 9222 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/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null +sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null +sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null +sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null +sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null +sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null +sips -z 1024 1024 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null +iconutil -c icns "$ICONSET_DIR" -o "$APP_DIR/Contents/Resources/LiteLLMUsage.icns" +cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' + + + +CFBundleDisplayNameLiteLLM Usage +CFBundleExecutableLiteLLMUsageMenuBar +CFBundleIdentifiercom.litellm.usage-menubar +CFBundleInfoDictionaryVersion6.0 +CFBundleNameLiteLLM Usage +CFBundlePackageTypeAPPL +CFBundleShortVersionString0.1.0 +CFBundleVersion1 +CFBundleIconFileLiteLLMUsage.icns +LSMinimumSystemVersion13.0 +LSUIElement + +PLIST +SIGNING_IDENTITY="${CODESIGN_IDENTITY:-LiteLLM Usage Development}" +if ! security find-identity -v -p codesigning | grep -Fq "\"$SIGNING_IDENTITY\""; then + printf 'Missing valid codesigning identity: %s\n' "$SIGNING_IDENTITY" >&2 + exit 1 +fi +codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null +rm -f "$DMG_PATH" +hdiutil create -volname "LiteLLM Usage" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" >/dev/null +printf '%s\n%s\n' "$APP_DIR" "$DMG_PATH" diff --git a/apps/macos-usage/scripts/fill-logo-background.swift b/apps/macos-usage/scripts/fill-logo-background.swift new file mode 100644 index 00000000000..ce2dd011762 --- /dev/null +++ b/apps/macos-usage/scripts/fill-logo-background.swift @@ -0,0 +1,104 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +guard CommandLine.arguments.count == 3 else { + fputs("usage: fill-logo-background.swift input.jpg output.png\n", stderr) + exit(2) +} + +let inputURL = URL(fileURLWithPath: CommandLine.arguments[1]) +let outputURL = URL(fileURLWithPath: CommandLine.arguments[2]) +let targetColor = (red: UInt8(135), green: UInt8(206), blue: UInt8(234)) + +guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), + let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { + fputs("could not read logo\n", stderr) + exit(1) +} + +let imageWidth = image.width +let imageHeight = image.height +let canvasSize = max(imageWidth, imageHeight) +let scale = 1.16 +let scaledWidth = Int((Double(imageWidth) * scale).rounded()) +let scaledHeight = Int((Double(imageHeight) * scale).rounded()) +let origin = CGPoint(x: (canvasSize - scaledWidth) / 2, y: (canvasSize - scaledHeight) / 2) +let colorSpace = CGColorSpaceCreateDeviceRGB() +var pixels = [UInt8](repeating: 0, count: canvasSize * canvasSize * 4) + +guard let context = CGContext( + data: &pixels, + width: canvasSize, + height: canvasSize, + bitsPerComponent: 8, + bytesPerRow: canvasSize * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue +) else { + fputs("could not create image context\n", stderr) + exit(1) +} + +context.setFillColor(red: CGFloat(targetColor.red) / 255, green: CGFloat(targetColor.green) / 255, blue: CGFloat(targetColor.blue) / 255, alpha: 1) +context.fill(CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize)) +context.draw(image, in: CGRect(origin: origin, size: CGSize(width: scaledWidth, height: scaledHeight))) + +func isBackgroundPixel(_ offset: Int) -> Bool { + let red = pixels[offset] + let green = pixels[offset + 1] + let blue = pixels[offset + 2] + let brightest = max(red, max(green, blue)) + let darkest = min(red, min(green, blue)) + return brightest >= 225 && brightest - darkest <= 24 +} + +var background = [Bool](repeating: false, count: canvasSize * canvasSize) +var queue: [Int] = [] + +func seed(_ index: Int) { + guard !background[index], isBackgroundPixel(index * 4) else { return } + background[index] = true + queue.append(index) +} + +for x in 0.. 0 { seed(index - 1) } + if x + 1 < canvasSize { seed(index + 1) } + if y > 0 { seed(index - canvasSize) } + if y + 1 < canvasSize { seed(index + canvasSize) } +} + +for index in queue { + let offset = index * 4 + pixels[offset] = targetColor.red + pixels[offset + 1] = targetColor.green + pixels[offset + 2] = targetColor.blue + pixels[offset + 3] = 255 +} + +guard let result = context.makeImage(), + let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { + fputs("could not write logo\n", stderr) + exit(1) +} +CGImageDestinationAddImage(destination, result, nil) +guard CGImageDestinationFinalize(destination) else { + fputs("could not finalize logo\n", stderr) + exit(1) +} From cf2f311d59904e042e5bf151b01d7e8b91db9066 Mon Sep 17 00:00:00 2001 From: leecoder Date: Tue, 22 Sep 2026 14:55:36 +0900 Subject: [PATCH 14/14] Revert "feat(macos-usage): add gateway quota menu bar app" This reverts commit affdef5b1edc8f8c2c8fbfaef389e6677b30efc8. --- apps/macos-usage/.gitignore | 3 - apps/macos-usage/DESIGN.md | 84 ----- apps/macos-usage/Package.swift | 9 - .../LiteLLMUsageMenuBar/GatewayClient.swift | 143 -------- .../LiteLLMUsageMenuBar/KeychainStore.swift | 80 ----- .../LiteLLMUsageMenuBarApp.swift | 34 -- .../Sources/LiteLLMUsageMenuBar/Models.swift | 176 ---------- .../LiteLLMUsageMenuBar/UsageStore.swift | 311 ------------------ .../Sources/LiteLLMUsageMenuBar/Views.swift | 198 ----------- apps/macos-usage/assets/litellm_logo.jpg | Bin 9222 -> 0 bytes apps/macos-usage/scripts/build-app.sh | 56 ---- .../scripts/fill-logo-background.swift | 104 ------ 12 files changed, 1198 deletions(-) delete mode 100644 apps/macos-usage/.gitignore delete mode 100644 apps/macos-usage/DESIGN.md delete mode 100644 apps/macos-usage/Package.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift delete mode 100644 apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift delete mode 100644 apps/macos-usage/assets/litellm_logo.jpg delete mode 100755 apps/macos-usage/scripts/build-app.sh delete mode 100644 apps/macos-usage/scripts/fill-logo-background.swift diff --git a/apps/macos-usage/.gitignore b/apps/macos-usage/.gitignore deleted file mode 100644 index 564c4754447..00000000000 --- a/apps/macos-usage/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.build/ -LiteLLMUsage.app/ -LiteLLMUsage.dmg diff --git a/apps/macos-usage/DESIGN.md b/apps/macos-usage/DESIGN.md deleted file mode 100644 index 62b0d981133..00000000000 --- a/apps/macos-usage/DESIGN.md +++ /dev/null @@ -1,84 +0,0 @@ -# LiteLLM Usage Menu Bar Design System - -## 0. Research Log -- Embedded refs: operational menu bar surface, using the taste-skill restraint rather than a marketing layout -- Lazyweb: skipped, this is a native macOS utility with no web surface -- Imagen drafts: skipped, a system utility has no image-led visual contract -- Apple platform reference: SwiftUI `MenuBarExtra` window style, available on macOS 13+ - -## 1. Atmosphere & Identity - -A quiet glanceable utility. The signature is a single signal, the current personal quota percentage, supported by a small amount of operational detail when the menu opens - -## 2. Color - -| Role | Token | Value | Usage | -|------|------|------|------| -| Surface | surface | system background | Menu popover | -| Text | text | primary | Main labels | -| Muted | muted | secondary | Supporting facts | -| Accent | accent | system blue | Refresh and settings actions | -| Success | success | system green | Healthy quota state | -| Warning | warning | system orange | High quota state | -| Error | error | system red | Failure state | - -## 3. Typography - -Primary: macOS system font. Mono: system monospaced font for amounts and percentages. Body text is at least 13pt - -## 4. Spacing & Layout - -Spacing derives from a 4pt base. The popover uses a 320pt width, 16pt outer padding, 12pt section gaps, and 8pt inline gaps - -## 5. Components - -### Menu bar status -- Structure: compact text label with percentage -- Variants: loading, healthy, warning, error, no quota -- States: default, disabled while loading -- Accessibility: label includes the full percentage and state - -### App icon -- Motif: the official LiteLLM logo aligned with the menu bar's 🚅 identity -- Source: the official square `assets/litellm_logo.jpg` mark with its sky-blue background extended across the canvas, generated into a standard macOS `.icns` bundle resource - -### Usage popover -- Structure: title, quota signal, usage facts, status line, action row -- Variants: configured, needs key, loading, success, failure, unavailable quota -- States: default, loading, error -- Accessibility: buttons have labels and visible text; color is never the only status signal - -### Settings form - - Structure: auto-refresh picker, gateway URL field, username field, secure password field, explanatory label, save action -- Variants: empty, configured -- States: focused, saving, saved; successful save dismisses the settings window -- Accessibility: secure text entry and explicit save action - -### Settings window - - Size: 380pt wide by 360pt high so the refresh picker, gateway URL, fields, explanation, and save action remain visible -- Presentation: a single independent window opened explicitly by the usage popover's settings action, never a sheet attached to the menu bar -- Focus: opening settings activates the app for keyboard input; closing the popover must not close settings -- Storage: username and password share one Keychain item; session tokens remain in memory for the running app - -### Usage scope picker -- Placement: top of the usage popover, above the quota signal -- Options: team total plus the authenticated user's named keys -- Fallback: a key without its own budget uses the team's monthly budget as its limit - -### Auto refresh -- Options: off, adaptive, 1 minute, 5 minutes, 15 minutes, 30 minutes, or 1 hour -- Default: adaptive -- Adaptive behavior: uses current utilization, recent spend rate, estimated time to limit, and reset time -- Behavior: refreshes the selected team or key budget while the menu bar app is running - -## 6. Motion & Interaction - -No decorative animation. Native popover transitions and button press feedback are retained. Refresh is explicit and periodic refresh does not steal focus - -## 7. Depth & Surface - -Use native macOS popover materials and tonal hierarchy. Do not add custom shadows or gradients - -## 8. Accessibility Constraints & Accepted Debt - -Percentage text remains readable without color. The app does not expose a custom chart or notification surface in this version diff --git a/apps/macos-usage/Package.swift b/apps/macos-usage/Package.swift deleted file mode 100644 index feb3f12f3c0..00000000000 --- a/apps/macos-usage/Package.swift +++ /dev/null @@ -1,9 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "LiteLLMUsageMenuBar", - platforms: [.macOS(.v13)], - products: [.executable(name: "LiteLLMUsageMenuBar", targets: ["LiteLLMUsageMenuBar"])], - targets: [.executableTarget(name: "LiteLLMUsageMenuBar")] -) diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift deleted file mode 100644 index 67efc11ffce..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/GatewayClient.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation - -struct GatewayClient: Sendable { - let baseURL: URL - - static let defaultBaseURL = URL(string: "https://api.litellm.ai")! - - static func baseURL(from input: String) -> URL? { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard var components = URLComponents(string: trimmed), - let scheme = components.scheme?.lowercased(), - ["http", "https"].contains(scheme), - let host = components.host, - !host.isEmpty, - components.user == nil, - components.password == nil, - components.query == nil, - components.fragment == nil else { return nil } - while components.path.count > 1, components.path.hasSuffix("/") { - components.path.removeLast() - } - return components.url - } - - func login(username: String, password: String) async throws -> AuthSession { - let url = baseURL.appending(path: "/v2/login") - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONSerialization.data(withJSONObject: ["username": username, "password": password]) - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } - guard httpResponse.statusCode == 200 else { - if httpResponse.statusCode == 401 { throw GatewayError.unauthorized } - throw GatewayError.server(httpResponse.statusCode) - } - let payload = try JSONDecoder().decode(LoginResponse.self, from: data) - guard let token = payload.token, let session = AuthSession(token: token) else { - throw GatewayError.invalidLoginResponse - } - return session - } - - func fetchUserInfo(apiKey: String) async throws -> UserInfoResponse { - let url = baseURL.appending(path: "/v2/user/info") - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } - switch httpResponse.statusCode { - case 200...299: break - case 401: throw GatewayError.unauthorized - case 403: throw GatewayError.forbidden - case 404: throw GatewayError.notFound - default: throw GatewayError.server(httpResponse.statusCode) - } - - return try decoder.decode(UserInfoResponse.self, from: data) - } - - func fetchTeamInfo(apiKey: String, teamID: String) async throws -> TeamBudget { - var components = URLComponents(url: baseURL.appending(path: "/team/info"), resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "team_id", value: teamID)] - guard let url = components?.url else { throw GatewayError.invalidResponse } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } - switch httpResponse.statusCode { - case 200...299: break - case 401: throw GatewayError.unauthorized - case 403: throw GatewayError.forbidden - case 404: throw GatewayError.notFound - default: throw GatewayError.server(httpResponse.statusCode) - } - return try decoder.decode(TeamInfoResponse.self, from: data).teamInfo - } - - func fetchKeyList(apiKey: String) async throws -> [String] { - let url = baseURL.appending(path: "/key/list") - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } - switch httpResponse.statusCode { - case 200...299: break - case 401: throw GatewayError.unauthorized - case 403: throw GatewayError.forbidden - case 404: throw GatewayError.notFound - default: throw GatewayError.server(httpResponse.statusCode) - } - return try decoder.decode(KeyListResponse.self, from: data).keys - } - - func fetchKeyInfo(apiKey: String, key: String) async throws -> KeyBudget { - var components = URLComponents(url: baseURL.appending(path: "/key/info"), resolvingAgainstBaseURL: false) - components?.queryItems = [URLQueryItem(name: "key", value: key)] - guard let url = components?.url else { throw GatewayError.invalidResponse } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { throw GatewayError.invalidResponse } - switch httpResponse.statusCode { - case 200...299: break - case 401: throw GatewayError.unauthorized - case 403: throw GatewayError.forbidden - case 404: throw GatewayError.notFound - default: throw GatewayError.server(httpResponse.statusCode) - } - return try decoder.decode(KeyInfoResponse.self, from: data).info - } - - private var decoder: JSONDecoder { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .custom { decoder in - let value = try decoder.singleValueContainer().decode(String.self) - if let date = ISO8601DateFormatter().date(from: value) { return date } - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSXXX" - guard let date = formatter.date(from: value) else { throw GatewayError.invalidResponse } - return date - } - return decoder - } - -} - -private struct LoginResponse: Decodable { - let token: String? -} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift deleted file mode 100644 index aa976a19408..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/KeychainStore.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import Security -import LocalAuthentication - -struct KeychainStore: Sendable { - private let service = "com.litellm.usage-menubar" - private let credentialsAccount = "ui-credentials" - - func readCredentials() throws -> (username: String, password: String)? { - guard let value = try read(account: credentialsAccount) else { return nil } - return try JSONDecoder().decode(StoredCredentials.self, from: Data(value.utf8)).tuple - } - - func saveCredentials(username: String, password: String) throws { - let value = try JSONEncoder().encode(StoredCredentials(username: username, password: password)) - try save(String(data: value, encoding: .utf8) ?? "", account: credentialsAccount) - } - - private func read(account: String) throws -> String? { - let context = LAContext() - context.interactionNotAllowed = true - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecUseAuthenticationContext as String: context - ] - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw KeychainError(status) - } - return String(data: data, encoding: .utf8) - } - - private func save(_ value: String, account: String) throws { - let data = Data(value.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account - ] - var item = query - item[kSecValueData as String] = data - let addStatus = SecItemAdd(item as CFDictionary, nil) - if addStatus == errSecSuccess { return } - guard addStatus == errSecDuplicateItem else { - throw KeychainError(addStatus) - } - let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) - if updateStatus != errSecSuccess { - throw KeychainError(updateStatus) - } - } -} - -private struct StoredCredentials: Codable { - let username: String - let password: String - - var tuple: (username: String, password: String) { - (username, password) - } -} - -struct KeychainError: LocalizedError, Equatable { - let status: OSStatus - - init(_ status: OSStatus) { self.status = status } - - var errorDescription: String? { - if status == errSecInteractionNotAllowed || status == errSecAuthFailed || status == errSecUserCanceled { - return "저장된 로그인 정보에 접근할 수 없습니다. 설정에서 다시 저장하세요. Keychain 승인은 저장할 때만 요청합니다" - } - return "Keychain 처리에 실패했습니다 (\(status))" - } -} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift deleted file mode 100644 index cc0d2400c43..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/LiteLLMUsageMenuBarApp.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -@main -struct LiteLLMUsageMenuBarApp: App { - @StateObject private var store = UsageStore() - - var body: some Scene { - MenuBarExtra { - UsagePopover().environmentObject(store) - } label: { - MenuBarLabel().environmentObject(store) - } - .menuBarExtraStyle(.window) - - Window("로그인 설정", id: "login-settings") { - SettingsView().environmentObject(store) - } - .windowResizability(.contentSize) - .defaultPosition(.center) - } -} - -private struct MenuBarLabel: View { - @EnvironmentObject private var store: UsageStore - - var body: some View { - switch store.state { - case let .ready(snapshot): Text("🚅 \(snapshot.percentage.formatted(.number.precision(.fractionLength(0))))%") - case .loading: Text("🚅 ...") - case .needsKey: Text("🚅 --") - case .unavailable, .failure: Text("🚅 !") - } - } -} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift deleted file mode 100644 index a2501916e3a..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Models.swift +++ /dev/null @@ -1,176 +0,0 @@ -import Foundation - -struct UserInfoResponse: Decodable, Sendable { - let userID: String - let spend: Double - let maxBudget: Double? - let budgetDuration: String? - let budgetResetAt: Date? - let teams: [String] - - enum CodingKeys: String, CodingKey { - case userID = "user_id" - case spend - case maxBudget = "max_budget" - case budgetDuration = "budget_duration" - case budgetResetAt = "budget_reset_at" - case teams - } -} - -struct TeamInfoResponse: Decodable, Sendable { - let teamInfo: TeamBudget - - enum CodingKeys: String, CodingKey { - case teamInfo = "team_info" - } -} - -struct TeamBudget: Decodable, Sendable { - let spend: Double - let maxBudget: Double? - let budgetDuration: String? - let budgetResetAt: Date? - - enum CodingKeys: String, CodingKey { - case spend - case maxBudget = "max_budget" - case budgetDuration = "budget_duration" - case budgetResetAt = "budget_reset_at" - } -} - -struct KeyListResponse: Decodable, Sendable { - let keys: [String] -} - -struct KeyInfoResponse: Decodable, Sendable { - let info: KeyBudget - - enum CodingKeys: String, CodingKey { - case info - } -} - -struct KeyBudget: Decodable, Sendable { - let keyAlias: String? - let keyName: String - let spend: Double - let maxBudget: Double? - let budgetDuration: String? - let budgetResetAt: Date? - let teamID: String? - - enum CodingKeys: String, CodingKey { - case keyAlias = "key_alias" - case keyName = "key_name" - case spend - case maxBudget = "max_budget" - case budgetDuration = "budget_duration" - case budgetResetAt = "budget_reset_at" - case teamID = "team_id" - } -} - -struct GatewayKey: Identifiable, Equatable, Sendable { - let value: String - let keyName: String - let alias: String? - - var id: String { keyName } - var displayName: String { alias ?? keyName } -} - -struct AuthSession: Sendable { - let token: String - let apiKey: String - let expiresAt: Date - - init?(token: String) { - let segments: [Substring] = token.split(separator: ".") - guard segments.count == 3 else { return nil } - var encoded = String(segments[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - encoded += String(repeating: "=", count: (4 - encoded.count % 4) % 4) - guard let data = Data(base64Encoded: encoded), - let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let apiKey = payload["key"] as? String, - let exp = payload["exp"] as? TimeInterval else { return nil } - self.token = token - self.apiKey = apiKey - self.expiresAt = Date(timeIntervalSince1970: exp) - } -} - -enum UsageState: Equatable, Sendable { - case needsKey - case loading - case ready(UsageSnapshot) - case unavailable(String) - case failure(String) -} - -enum AutoRefreshInterval: Int, CaseIterable, Identifiable, Sendable { - case off = 0 - case adaptive = -1 - case oneMinute = 60 - case fiveMinutes = 300 - case fifteenMinutes = 900 - case thirtyMinutes = 1800 - case oneHour = 3600 - - var id: Self { self } - - var fixedSeconds: TimeInterval? { - switch self { - case .off, .adaptive: nil - case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: - TimeInterval(rawValue) - } - } - - var title: String { - switch self { - case .off: "끔" - case .adaptive: "적응형" - case .oneMinute: "1분" - case .fiveMinutes: "5분" - case .fifteenMinutes: "15분" - case .thirtyMinutes: "30분" - case .oneHour: "1시간" - } - } -} - -struct UsageSnapshot: Equatable, Sendable { - let spend: Double - let limit: Double - let percentage: Double - let budgetDuration: String - let resetAt: Date? -} - -enum GatewayError: LocalizedError { - case invalidResponse - case unauthorized - case forbidden - case notFound - case server(Int) - case noUserBudget - case notMonthlyBudget(String) - case invalidLoginResponse - case invalidGatewayURL - - var errorDescription: String? { - switch self { - case .invalidResponse: "게이트웨이 응답을 읽을 수 없습니다" - case .unauthorized: "로그인 정보가 올바르지 않거나 세션이 만료되었습니다" - case .forbidden: "개인 사용량 조회가 거부되었습니다 (HTTP 403)" - case .notFound: "사용자를 찾을 수 없습니다" - case let .server(code): "게이트웨이 오류 (HTTP \(code))" - case .noUserBudget: "개인 예산 한도가 설정되지 않았습니다" - case let .notMonthlyBudget(duration): "개인 예산이 월 단위가 아닙니다 (\(duration))" - case .invalidLoginResponse: "로그인 응답을 읽을 수 없습니다" - case .invalidGatewayURL: "올바른 게이트웨이 URL을 입력하세요 (예: https://gateway.example.com)" - } - } -} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift deleted file mode 100644 index 40eb7e8e3d0..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/UsageStore.swift +++ /dev/null @@ -1,311 +0,0 @@ -import Foundation -import Combine - -@MainActor -final class UsageStore: ObservableObject { - @Published private(set) var state: UsageState = .needsKey - @Published private(set) var lastUpdated: Date? - @Published private(set) var availableKeys: [GatewayKey] = [] - @Published private(set) var selectedKeyName: String? - @Published private(set) var keyListMessage: String? - @Published private(set) var isLoadingKeys = false - @Published private(set) var autoRefreshInterval: AutoRefreshInterval - private let keychain: KeychainStore - private var client: GatewayClient - private(set) var gatewayURL: URL - private var credentials: (username: String, password: String)? - private var session: AuthSession? - private var isRefreshing = false - private var refreshTimer: Timer? - private var lastSpendSample: SpendSample? - private var smoothedSpendPerDay: Double? - private let selectedKeyDefaultsKey = "selected-key-name" - private let refreshIntervalDefaultsKey = "auto-refresh-interval" - private let gatewayURLDefaultsKey = "gateway-url" - - init(keychain: KeychainStore = KeychainStore(), client: GatewayClient? = nil) { - let configuredURL = client?.baseURL ?? Self.loadGatewayURL() - self.keychain = keychain - self.client = client ?? GatewayClient(baseURL: configuredURL) - gatewayURL = configuredURL - selectedKeyName = UserDefaults.standard.string(forKey: selectedKeyDefaultsKey) - let storedInterval = UserDefaults.standard.object(forKey: refreshIntervalDefaultsKey) as? Int - autoRefreshInterval = AutoRefreshInterval(rawValue: storedInterval ?? AutoRefreshInterval.adaptive.rawValue) ?? .adaptive - do { - credentials = try keychain.readCredentials() - state = hasCredentials ? .loading : .needsKey - } catch { - state = .failure(error.localizedDescription) - } - configureRefreshTimer() - Task { [weak self] in - await self?.loadKeys() - await self?.refresh() - } - } - - var hasCredentials: Bool { credentials != nil } - - var savedUsername: String { credentials?.username ?? "" } - var savedPassword: String { credentials?.password ?? "" } - - func saveSettings(gatewayURL input: String, username: String, password: String) throws { - guard let newGatewayURL = GatewayClient.baseURL(from: input) else { - throw GatewayError.invalidGatewayURL - } - let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedUsername.isEmpty, !password.isEmpty else { throw KeychainError(errSecParam) } - try keychain.saveCredentials(username: trimmedUsername, password: password) - UserDefaults.standard.set(newGatewayURL.absoluteString, forKey: gatewayURLDefaultsKey) - gatewayURL = newGatewayURL - client = GatewayClient(baseURL: newGatewayURL) - credentials = (trimmedUsername, password) - session = nil - availableKeys = [] - keyListMessage = nil - lastSpendSample = nil - smoothedSpendPerDay = nil - configureRefreshTimer() - Task { - while isRefreshing { - try? await Task.sleep(for: .milliseconds(50)) - } - await loadKeys() - await refresh() - } - } - - func setAutoRefreshInterval(_ interval: AutoRefreshInterval) { - guard autoRefreshInterval != interval else { return } - autoRefreshInterval = interval - UserDefaults.standard.set(interval.rawValue, forKey: refreshIntervalDefaultsKey) - configureRefreshTimer() - } - - func refresh() async { - guard !isRefreshing, credentials != nil else { return } - isRefreshing = true - defer { - isRefreshing = false - scheduleNextRefresh() - } - do { - let budget: TeamBudget - do { - let auth = try await authenticatedSession() - budget = try await fetchCurrentBudget(apiKey: auth.apiKey) - } catch GatewayError.unauthorized { - session = nil - let auth = try await authenticatedSession() - budget = try await fetchCurrentBudget(apiKey: auth.apiKey) - } - guard let limit = budget.maxBudget, limit > 0 else { throw GatewayError.noUserBudget } - guard let duration = budget.budgetDuration, isMonthly(duration) else { - throw GatewayError.notMonthlyBudget(budget.budgetDuration ?? "없음") - } - let now = Date() - updateSpendRate(spend: budget.spend, at: now) - state = .ready(UsageSnapshot( - spend: budget.spend, - limit: limit, - percentage: max(0, budget.spend / limit * 100), - budgetDuration: duration, - resetAt: budget.budgetResetAt - )) - lastUpdated = now - } catch let error as GatewayError { - state = .failure(error.localizedDescription) - } catch { - state = .failure("게이트웨이에 연결할 수 없습니다") - } - } - - func loadKeys() async { - guard !isLoadingKeys, hasCredentials, availableKeys.isEmpty else { return } - isLoadingKeys = true - keyListMessage = nil - defer { isLoadingKeys = false } - do { - let auth: AuthSession - do { - auth = try await authenticatedSession() - } catch GatewayError.unauthorized { - session = nil - auth = try await authenticatedSession() - } - let keys = try await fetchKeyOptions(apiKey: auth.apiKey) - availableKeys = keys - if let selectedKeyName, !keys.contains(where: { $0.keyName == selectedKeyName }) { - self.selectedKeyName = nil - UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) - } - } catch let error as GatewayError { - keyListMessage = error.localizedDescription - } catch { - keyListMessage = "키 목록을 불러올 수 없습니다" - } - } - - func selectKey(named keyName: String?) { - guard selectedKeyName != keyName else { return } - selectedKeyName = keyName - lastSpendSample = nil - smoothedSpendPerDay = nil - if let keyName { - UserDefaults.standard.set(keyName, forKey: selectedKeyDefaultsKey) - } else { - UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) - } - Task { await refresh() } - } - - private func configureRefreshTimer() { - refreshTimer?.invalidate() - refreshTimer = nil - scheduleNextRefresh() - } - - private func scheduleNextRefresh() { - refreshTimer?.invalidate() - guard hasCredentials, let delay = nextRefreshDelay() else { - refreshTimer = nil - return - } - refreshTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in - Task { @MainActor [weak self] in - await self?.refresh() - } - } - } - - private func nextRefreshDelay() -> TimeInterval? { - switch autoRefreshInterval { - case .off: - return nil - case .adaptive: - guard case let .ready(snapshot) = state else { return 900 } - return adaptiveRefreshDelay(for: snapshot) - case .oneMinute, .fiveMinutes, .fifteenMinutes, .thirtyMinutes, .oneHour: - return autoRefreshInterval.fixedSeconds - } - } - - private func adaptiveRefreshDelay(for snapshot: UsageSnapshot) -> TimeInterval { - if snapshot.percentage >= 100 { - guard let resetAt = snapshot.resetAt else { return 86_400 } - return min(86_400, max(60, resetAt.timeIntervalSinceNow - 60)) - } - - let remaining = max(0, snapshot.limit - snapshot.spend) - if let smoothedSpendPerDay, smoothedSpendPerDay > 0 { - let secondsUntilLimit = remaining / smoothedSpendPerDay * 86_400 - if secondsUntilLimit <= 6 * 3_600 { return 60 } - if secondsUntilLimit <= 24 * 3_600 { return 300 } - if secondsUntilLimit <= 3 * 86_400 { return 900 } - } - - if snapshot.percentage >= 95 { return 300 } - if snapshot.percentage >= 80 { return 900 } - if snapshot.percentage >= 50 { return 3_600 } - return 14_400 - } - - private func updateSpendRate(spend: Double, at date: Date) { - if let lastSpendSample { - let elapsed = date.timeIntervalSince(lastSpendSample.date) - if elapsed > 0 { - let spendDelta = spend - lastSpendSample.spend - if spendDelta < 0 { - smoothedSpendPerDay = nil - } else { - let spendPerDay = spendDelta / elapsed * 86_400 - smoothedSpendPerDay = smoothedSpendPerDay.map { $0 * 0.7 + spendPerDay * 0.3 } ?? spendPerDay - } - } - } - lastSpendSample = SpendSample(spend: spend, date: date) - } - - private struct SpendSample { - let spend: Double - let date: Date - } - - private func authenticatedSession() async throws -> AuthSession { - if let session, session.expiresAt.timeIntervalSinceNow > 300 { return session } - guard let credentials else { throw GatewayError.unauthorized } - let refreshed = try await client.login(username: credentials.username, password: credentials.password) - session = refreshed - return refreshed - } - - private func fetchBudget(apiKey: String) async throws -> TeamBudget { - let userInfo = try await client.fetchUserInfo(apiKey: apiKey) - if userInfo.maxBudget != nil { - return TeamBudget( - spend: userInfo.spend, - maxBudget: userInfo.maxBudget, - budgetDuration: userInfo.budgetDuration, - budgetResetAt: userInfo.budgetResetAt - ) - } - guard let teamID = userInfo.teams.first else { throw GatewayError.noUserBudget } - return try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) - } - - private func fetchCurrentBudget(apiKey: String) async throws -> TeamBudget { - guard let selectedKeyName else { - return try await fetchBudget(apiKey: apiKey) - } - - let keys = availableKeys.isEmpty ? try await fetchKeyOptions(apiKey: apiKey) : availableKeys - availableKeys = keys - guard let selectedKey = keys.first(where: { $0.keyName == selectedKeyName }) else { - self.selectedKeyName = nil - UserDefaults.standard.removeObject(forKey: selectedKeyDefaultsKey) - return try await fetchBudget(apiKey: apiKey) - } - - let keyInfo = try await client.fetchKeyInfo(apiKey: apiKey, key: selectedKey.value) - if let maxBudget = keyInfo.maxBudget { - return TeamBudget( - spend: keyInfo.spend, - maxBudget: maxBudget, - budgetDuration: keyInfo.budgetDuration, - budgetResetAt: keyInfo.budgetResetAt - ) - } - guard let teamID = keyInfo.teamID else { throw GatewayError.noUserBudget } - let teamBudget = try await client.fetchTeamInfo(apiKey: apiKey, teamID: teamID) - return TeamBudget( - spend: keyInfo.spend, - maxBudget: teamBudget.maxBudget, - budgetDuration: teamBudget.budgetDuration, - budgetResetAt: teamBudget.budgetResetAt - ) - } - - private func fetchKeyOptions(apiKey: String) async throws -> [GatewayKey] { - let keys = try await client.fetchKeyList(apiKey: apiKey) - var options: [GatewayKey] = [] - for key in keys { - let info = try await client.fetchKeyInfo(apiKey: apiKey, key: key) - options.append(GatewayKey(value: key, keyName: info.keyName, alias: info.keyAlias)) - } - return options - } - - private func isMonthly(_ duration: String) -> Bool { - let normalized = duration.lowercased() - return ["1mo", "1month", "30d", "monthly"].contains(normalized) - } - - private static func loadGatewayURL() -> URL { - guard let savedURL = UserDefaults.standard.string(forKey: "gateway-url"), - let gatewayURL = GatewayClient.baseURL(from: savedURL) else { - return GatewayClient.defaultBaseURL - } - return gatewayURL - } - -} diff --git a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift b/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift deleted file mode 100644 index 99c7927a0bb..00000000000 --- a/apps/macos-usage/Sources/LiteLLMUsageMenuBar/Views.swift +++ /dev/null @@ -1,198 +0,0 @@ -import SwiftUI - -struct UsagePopover: View { - @EnvironmentObject private var store: UsageStore - @Environment(\.openWindow) private var openWindow - private let teamSelection = "__team__" - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - scopePicker - content - Divider() - actions - } - .padding(16) - .frame(width: 320) - .task { - await store.loadKeys() - await store.refresh() - } - } - - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("LiteLLM 사용량").font(.headline) - } - Spacer() - statusIcon - } - } - - private var scopePicker: some View { - VStack(alignment: .leading, spacing: 4) { - Picker("조회 대상", selection: Binding( - get: { store.selectedKeyName ?? teamSelection }, - set: { store.selectKey(named: $0 == teamSelection ? nil : $0) } - )) { - Text("팀 전체").tag(teamSelection) - ForEach(store.availableKeys) { key in - Text(key.displayName).tag(key.keyName) - } - } - .pickerStyle(.menu) - .disabled(!store.hasCredentials || store.isLoadingKeys) - if let keyListMessage = store.keyListMessage, !store.isLoadingKeys { - Text(keyListMessage).font(.caption).foregroundStyle(.secondary) - } - } - } - - @ViewBuilder - private var content: some View { - switch store.state { - case .needsKey: - MessageView(title: "로그인 정보를 설정하세요", detail: "설정에서 게이트웨이 ID와 비밀번호를 입력하면 사용량을 조회합니다", symbol: "person.crop.circle.badge.key") - case .loading: - HStack(spacing: 8) { ProgressView(); Text("사용량을 확인하는 중...").foregroundStyle(.secondary) } - case let .ready(snapshot): - VStack(alignment: .leading, spacing: 10) { - Text(snapshot.percentage.formatted(.number.precision(.fractionLength(0))) + "%") - .font(.system(size: 38, weight: .bold, design: .rounded)) - .foregroundStyle(color(for: snapshot.percentage)) - ProgressView(value: min(snapshot.percentage, 100), total: 100) - .tint(color(for: snapshot.percentage)) - HStack { - Fact(label: "사용액", value: currency(snapshot.spend)) - Spacer() - Fact(label: "한도", value: currency(snapshot.limit)) - } - if let resetAt = snapshot.resetAt { - Text("다음 초기화 " + resetAt.formatted(date: .abbreviated, time: .shortened)) - .font(.caption).foregroundStyle(.secondary) - } - } - case let .unavailable(message), let .failure(message): - MessageView(title: "사용량을 표시할 수 없습니다", detail: message, symbol: "exclamationmark.triangle") - } - } - - private var actions: some View { - HStack { - Button("새로고침") { Task { await store.refresh() } } - .buttonStyle(.borderless) - .disabled(store.state == .loading || !store.hasCredentials) - Spacer() - Button("설정") { - openWindow(id: "login-settings") - NSApplication.shared.activate(ignoringOtherApps: true) - } - .buttonStyle(.borderless) - Button("종료") { NSApplication.shared.terminate(nil) } - .buttonStyle(.borderless) - } - .font(.callout) - } - - @ViewBuilder - private var statusIcon: some View { - switch store.state { - case .ready(let snapshot): Image(systemName: "circle.fill").foregroundStyle(color(for: snapshot.percentage)) - case .loading: ProgressView().controlSize(.small) - case .needsKey: Image(systemName: "key.fill").foregroundStyle(.secondary) - case .unavailable, .failure: Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red) - } - } - - private func color(for percentage: Double) -> Color { - percentage >= 90 ? .red : percentage >= 75 ? .orange : .green - } - - private func currency(_ value: Double) -> String { value.formatted(.currency(code: "USD")) } -} - -struct SettingsView: View { - @EnvironmentObject private var store: UsageStore - @Environment(\.dismiss) private var dismiss - @State private var gatewayURL = "" - @State private var username = "" - @State private var password = "" - @State private var message: String? - - var body: some View { - Form { - Section("사용량") { - Picker("자동 새로고침", selection: Binding( - get: { store.autoRefreshInterval }, - set: { store.setAutoRefreshInterval($0) } - )) { - ForEach(AutoRefreshInterval.allCases) { interval in - Text(interval.title).tag(interval) - } - } - Text("팝오버가 닫혀 있어도 메뉴바 사용량을 갱신합니다") - .font(.caption).foregroundStyle(.secondary) - } - Section("게이트웨이") { - TextField("서버 주소", text: $gatewayURL) - TextField("아이디", text: $username) - SecureField("비밀번호", text: $password) - Text("LiteLLM 프록시 서버 주소를 입력하세요") - .font(.caption).foregroundStyle(.secondary) - Text("로그인 정보는 macOS Keychain에 저장되며 세션은 앱 실행 중 메모리에만 유지됩니다") - .font(.caption).foregroundStyle(.secondary) - Button("저장") { - guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { - message = "아이디와 비밀번호를 모두 입력하세요" - return - } - do { - try store.saveSettings(gatewayURL: gatewayURL, username: username, password: password) - username = "" - password = "" - dismiss() - } catch { message = error.localizedDescription } - } - .disabled(gatewayURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || password.isEmpty) - if let message { Text(message).font(.caption).foregroundStyle(.secondary) } - } - } - .formStyle(.grouped) - .frame(width: 380, height: 360) - .padding() - .onAppear { - gatewayURL = store.gatewayURL.absoluteString - username = store.savedUsername - password = store.savedPassword - } - } -} - -private struct MessageView: View { - let title: String - let detail: String - let symbol: String - - var body: some View { - Label { - VStack(alignment: .leading, spacing: 3) { - Text(title).font(.subheadline.weight(.semibold)) - Text(detail).font(.caption).foregroundStyle(.secondary) - } - } icon: { Image(systemName: symbol).foregroundStyle(.secondary) } - } -} - -private struct Fact: View { - let label: String - let value: String - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.caption).foregroundStyle(.secondary) - Text(value).font(.system(.callout, design: .monospaced)) - } - } -} diff --git a/apps/macos-usage/assets/litellm_logo.jpg b/apps/macos-usage/assets/litellm_logo.jpg deleted file mode 100644 index 6fe96e2ed3567525bfc6487534fc1fb82a9fbf06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9222 zcmbU`XIN9)vYTEMBLdRNIVc^ZD?NgTCL#i2M3e}qbP%PNM5H4UP>KQ)dWT38si7l+ zAib$T=uHy9kU+|d=iGbW{rSE($=aE$Z|${b_VhKhY1$(2*)Y)c5dfH*1F`@Bumh|N zX8tkF2=6|iH@A)^F{OkWezideW zz(${8R#H(>PGt@KKzfhKcDEv3obUNvTv9WV-o;VFKGB7bQ zGBdHz>7}m>`~6y&xmnKqsjSb+W9h&q?!&7Ro}9~m&Y-%3&uRpBUe)o*a}Lh4`~reP z5|UEVGO}vw7cXgOYF+uu(8$=t^s4nun_JM^ws)K!JbdKr;_CL)*Uvv7Feo@8GAcSI zHZK0v>y*^A^fzxa^70D`i;7E1%W7)t>OVAmY;5Z6>h9_7`_%t=bZmTLa%y^Jb`iU@ zyt2BszOjkl+due4I3$ve=yLjp9s2bjj{b`uZaP1V%*;&8Y`^(oU<{xSCT?byKb2X} z=v%Tm`0$9UgtPM+BAWPIG=pp5Gi=faR>S*S*?)}aXcn7RxHoXPd!HWAiIip{ z6UvI&B=dP6+Sj>uWqb7tXS)%qVBOiuxV}jF>$zC7X^&j{z4x$vBD8}B@IgE>;|4P< zI@do)`cppm(SQ+GirpSMI#eIM^+Oh;g^JykXv?AjB40itiyx8!^k|JDm$F7=nIr6c zh#!OtJ1n9UXse~czF$9;d!)Yvp0nuCx`}pQnA7w#7U5v~nKwM1fa-8x#hjr|p#;>w zjBM1{x%y;7e8dPepbbTqTgfTLtWbmDd=l{DXA3$t&l{vv_aU5LLam2&s` zMDW3-+y$v4Q@beL%WrbXuLY`%?m=%gu5SJe0v%gxQtJ)kkFMJKv0u2YFR>o_&|{9z z@`s-=DUD^~>#~_NT4`KBw?yT`+o4y}bU{6${9qhGw zy?1qA8&GoK%7J0&+rbBWc{G6KlWjQJgzXm^fkW>!Vl!3V2R56=?stIeztt1kP}-iG}MZf$9*8yRyZ&lUR8??Ex^=z2O5yp3wX z#H_%w*2MVM*Pf;KqiP=aAG6bd8~c&T&C@? z6=W^b*V_wq_2^HDd(G*EaI?gAn=bRQN?|pG!NZ_QeqqyFo#v)NXV*)*LL1d~+C`v( zo4MmwOvz@KS$HikgK1xXt6m1jTn1ounfLp&$Y>f60v(yo$P)v1c1uDFIyPwSv^Vb}C69uZSH^(39CjdEt49;ih|@PFU%_F(R^&BA0|$jQce&;BFll(;cAj zG(eN|wuxaqpLltjjV}T=LYC>c`GhDlK3Tl7j^M}7$KB5Q;l)dty~mHZu^KOpZh$D8 zp8Uszew$JJ>979cZzE7iW9!QaJjNx-;%4$HaiY26 z!6HdSC#*+Nh~ByQ!}31r;K$eL-#FtRdmX4NoeQ07=`YwXj}bs_Aay|wMF&ViK? z)K~SFzfyOy0@5zj8j437TX7}@pEgFj)#-Vqk_2%)gqU%-z>mJ1z(k{uUmyJ8dE7e( z=|*h_qa8*uw-3Hw9kZ@|apQgR!{;`q5?h`ZJm5~8Mz2;$EH=$}+Fxz zQ;DZTaM@6k5vpLI6@uF>@Ehy2)6mIRVtmG=4cLnxs(5(o5{CO*U}pE3px58PM9z}+ zcx~xzxe0^|EPe{itK%&-Lw5%cHwH)UltASCUG<7{2xw+a1=J`ep9qRdJ8`Of+@6eK znWoC)%@Q&7K2}{Lh^FZ5HU~pq==?M}^?hEvOzeM2f@S%6{N0ORrx!*=K&^dK@t#k} zhF72ucyCsBsLgNQz#QkR*Rn;0nmkR$PUbmm1L^*EZ_d7qg&}_ zZams+Orpqb56?I)u;$`gl*7do2tk`A>m+Yo}4%_ zjC$Z(eIP1?QaslXMMQF^I_AdZ5xxh3zXn4FlC&n`Ns5Hq#C96Mts@xCmN$4p(|3Bp?e>k({FY{)5OXqTo574K|S*DvjSz>rU(;1Udy{|6$siWl@bt7 zVk0yVv#21P2JS=Zg@GL>K!OwrJAKhN5J%Z=={fG&j@S;XCC6H2(x#hU|_<|J2bvv;Lv7{WiZMv61hK z&h?CQn@TMbR%VBZKa9VEdGs_wRf#fOtyA4DyUTWYKW^8&7(CH%ykb1C(-TB}LHEBp zlvLk@6P~hI$dT&>l#eeB_{53-2xkf$>#lk6hl=EbiB~=&zCgd zf-(u(yLMNj`srHfy}o{M8LmxS&`*Lh@OJ;9%aO1N4R~G-?^o&P`%qrBb>yhY**kYR zMSKmpXy5}Y5o4(*!tjvJTVgsm?*#AqRIA1;HfAwpSabsos1t|SKZ zz{m2gH={XQi?WUCO%Ui})g?megorsG^HBkOs2_)JKSKj5F%u7l5z0}aG5EQzHZX1~ zZbv~!Yplh}ZrADYai%|3>j?U_xNrC=5aP;||Lij~mn~PsSGRl>ksy@0nenT>yFf%A zIX)G&C)NA0`8lvQ;!btIxkOFq`vph88CL|i5pXHWkkpeGR1hkY-x7BR+M z0qC|h!`J=G`zx1}Q5&#CV*V@pET3vSdW^VoB-NfM@wigH#@o2U0)40;oc1bx<*wEY@nV-M`vJS!$~_ka|3-~?a=UM#>{2-GH#Z%U{fu)f7Z(}q|3*iD zNjSV{)=9dU5}M<0xRc#I^^9zXqX9eIc&{&^Qn=@%V_iRl;F1CFqIO0g`y=hgnC*Dt zwXW`KH7^n6vICho<4s#oBq>`QIay}yrrT8eh8S~vg$QbgTI9MQ@Rn{HsY;zbt{l|u zR}A~8L-#f9AmKQQ(dT`>*dvu;J*A=W@p->Mle^IhB z6!Lt@T6ww)^>__I)r3{sgNW)m#5*GjI?8!78_CkWe?*<5oQ77|k|FOtkVg5$!UHMp z`-1^-(`BQSZyxZ}aNHV~`JE?_vz~1yX+Xzi9sB0LehA{sn|(BAu;35v82_ zy!k<@SWvIIX>!{^a43ftMn5YFB1CDA=@9ZoY=wIuf?D;S*YC7I9N4(q-)!tvhW@fw z9^5kf{w_WFAP~;iJ_6$)K6Ii1%rA(_kFnlDfv@c+=ZS-dqYfAr_4OuW@)>V}%@}H^ zdgSN3>ccPH#c2Eb;}eyrZBNd9o0{05wD+dstjvs`8-bm^;6?dLoe)0^ZM7Tsg*b?Vu#JHA|!f?ry*%y`{Q9ZMnrFOSu> zyOf+z*=!!XVQPZgO;b+{*VHaItl;t8z)Yj@oy?n)L%CktPOrc<=R-X;?pr6<#S`ra z9d9q_Ac_XqHI6}#jm59P92WINX@GmFsju@2RYQXYIH)z}FVE6Fcku|>wpbs^gr)&y zZTXaBotv|4J#P;DwH+sNTr7?^_KSboQK)mpW97%TU4tV{cW-;$D zZJb(z@iM?WR}fY>U5S%rFPp&~ zKcuGoR0j=@wBhXZn158ez!)!g2|B5U1i3=BezrapeV3Z$DZhW!puiG zH;I2&5HT`jBuA>FR6sm1{M5m zb>%hDHizg1vVTFs|aW8Z?UFpeDOA6kn(JI)tv)s@@aJxP|mEq-rZyjSZa zYO+bX#colTQL%PzM;j5vDwowpY;U9ihB87&(7nH;trFJ`x{|lF5YG=#42k$ z#qqds2rcqYKbUGnbXd^UHNl)u0V?scS*@R$ZylkJx<|qZ(xQaFdSZu`6{#C=uzOj^YV5 z>Tr0%_&v3wPoQ&ZgYGMiu>nYW1|vA-hlmZ{p<#eZ(+btdNEzL7Z1ht(C^fKP`5U1K~sguKB7UbXYxhD+yT2U)36*7anE;@YcC?;I4bmeB{h#ejh zLAL7B3i>HdxktASdf*HTA`zjBQ)Ir7rL+dAI5qT4RsZaiQQD-g9n?<(J}zWr7D)F? zX%2Y`Ql*@&CpalR=ynWI9c9VM_@mx8YZA{eth>H?O16)p2suH|$L$7vg z`x}l6780s8df?{{o@0X?_hui*7&m;Pwl8eg+$`5ZK0>Y^cSGuWqLF2`F4F1gv(Y&< zEoUcvV*E{fS`$}ofCm9<^+xkTE!HYQC|n#dlA@4L1D1XfM?&yJ^HwHHttJCGj2Hc&v+%RE7@+^5Q= zl)?*M-sF@?5w*z}cElsptY0=(ZkW~WY@S_E>dDbr@v}2Ys)PhkA0s9Rv5)}c(mNlj zLS(|Wg0c2#vo_!RVnf&ST<^bm6P4QJ_1*h?iQdC=Ea8M6_{C{z53JTGbIoL@#N*Wu z6O>oJ+|M&7J!cW88aH4cX#mc2J_5tlc7n`}2gOkN4OMLKZ6RFJhf@Z7EnQDuUVA_N zqE{>75M922;(^pFm!CKSrxSZ#%W7UhWk9UyF_-d&dZo29!^B2Dgb^X@r~b9YBVO~5 zXS&0Tc?BtsE7S8_=AR23k#mS}g4Hzn58O&c{;C~2!iKfgnr*akp4FD?T&I?O8e-+g zqyUNTQh}=UL6{rWpl3LvqHq<;W&8@#3KLH1=E10_cz1?bj`XBuZLCJd*YU;9AV{T zbzE3+CkmE{E4)}Y&;)K})Y+nC+K9OO%iaiI>NXyV}<`kU3wlc;9o zeaO)Sxqu!k5uVIM8i3bqI~A(ktP%+A+^;*+FD#m#9uddB-OvAxE%#hv3^;xANN;67 z{b!jP{Cfvn;Zabkg|n8Ase092@1L@)OZOJ_u=rwqNtF|w#RZkLLT-*dT_IbVc(daeGZYJLIb$qmM?Le zDf{4U@<~$5u~?#W&-i-{sh7R$$)7B+h{*S%4e$$Nj=Bx@2ADy_uX0_oC(g#yf_Pmm zyRmOB2W?A#TroW=tF-ZjG7uHn`YdLqE~B=a3}-K3JXdwLxFX}BJC@HmU^0Zakm>$D zeOTmI^2M>;%HI59*ZvUi$GIa5$_@6}S;5fP*^kvdn$Yj70v`AAIn;VwntY7BLSP;4 zg|OBOPy6m@_cmQ?xTGr4Vp(~h{litzlG`ZA|B;R5w@^JUCjd%o+sF@^Bjg?Y9zzwG-zX3yc~#XjcB`g~rj{B`i5 zd)(3xVVqAgyfe$wTdZ^O-sLrLZke(XA3nC){fhW2${&Pn{Nh0xg zsMlMNyXzyVbr9MVmHge5kHm6(lgQB`jK3Oom8GZXz1{>^goBm&1?d?=h!U=IN^T7{ z3t5aMzQ!E|R=kGi--nO58C%^9?WX~Z_Ie-DRSQ|Wn;Pr=WJM$RQ*6vhodLO&`>pG4 zZTCQZvNBg|L=!oaJJ_m4jTcQY%I!O_@kDdm!Ws1bP?ul7=3Fr6# zAUa`>X~3=>y)3(fam5?S7T&RVI)fBmm806DUPe$k7#6->MDn&lwnh-Wuc;z5U;#@v zIu2{(v9CSk4ke{WpfqLfmhe)=02Pi(ZUT|Hod{P(e}$L1L)*?zIw-F8KlCdbQYd)Z z|6NFlg@c}XIj{7&QLf`+B3k6==M$X-drsW-cdV%e8zbzVl?qA5ye9b!Hqc*HY4LmS zgY?cuW|ito4)d#Hb-XSP@LBd>c*pLgP^qYJzPdH;QM8S6ixO6MCi8Eb}Nq3 z0>U+o&r1$`xkjma9Q<-=8Ajv`hrPqpu61d)Ns_O8sCmaRwte-Ry;zSv>D&S76VnE) z7WY@O5nmwFGd70}A{479<6Qb#L&)3HmI7ITa$e<5bGGp=2-8M#FIbWMB>0Gjst|gC zNAH4AJ~2DyC|9wU^KpwjbK!x0C6`jl+dyE}z$D_*UoV6n6-(_tJAOX|f5-F-jU>fB z?bV_(k>w8idhE}R>Nw@xB_i&|ntA$0iNag^az)GW<=Df{cd*4vadTJ3^He;{e^2~9WP1t=Cn(=;omEyF8yXPt$r(^p z?tG@bTK>RE_gOyyJ&K&MUXXJ+ZH!m!y?E?h1Urzv`}Cv-WKxNGd z@)8|S?->ERw|^70)9f{hf<%;WBZG+?xa_$_i|3A`k_`1HUP-nj`KoXq8Xrq*@ck@P zQoa$%6dsPW*tjJ}dde4<>Xuk>=zQpZFMYK0Yob_uonr-^AfsB)OJ4f zB5tjF_~gLi{OO)l?+i`;n7Cstv2J*Ng#F8Fe>m3X+WQujS^K*z_uk)t7OYRa#2$fT zP`uPL#LPvbCf`cefH{+=o=+k;Wa+I&zdk?mN3{HbD5x0m852ZyT0op4?i!}+;|*)> z*_mkG5(POHSBvFg6sRij!5>mYkG)MQ7&=~OBher|_#R}+>t{I=vlLIE0g)QiG#Pn4 znZ040^PX|69uKtL9Q)WT1LajtW!UH9U94aFmK8q6`y=(}A+kH0z$u*<{=1j93Ut>C8?ZpAM$J^Hy} zVfk{zaM*`M-kV13?@S(_Lfkp_KqlhfNPL=C3sK!0lEJ-}8?-Cs>%Xu?My|Dhu4-d3 zt}U*|x}nne4R-a5tohmO=SKUrhc-yCc9;+?%=hwRSEUx)>cIXoKGOGKuP z*l#zsf~r2Z2oF^&&c}%Vq&G;&y<=*X7IrQ7v<*MJ`Wc{cd-2{jf(4Ck6QcIn%T8Sj zMk0%-{dK*ev7cN&9Z3DmA#WXcciEr7VIma_koTHPHBaNhZNS655v=6dhQz0>U/dev/null -sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_16x16@2x.png" >/dev/null -sips -z 32 32 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32.png" >/dev/null -sips -z 64 64 "$ICON_PNG" --out "$ICONSET_DIR/icon_32x32@2x.png" >/dev/null -sips -z 128 128 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128.png" >/dev/null -sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_128x128@2x.png" >/dev/null -sips -z 256 256 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256.png" >/dev/null -sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_256x256@2x.png" >/dev/null -sips -z 512 512 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512.png" >/dev/null -sips -z 1024 1024 "$ICON_PNG" --out "$ICONSET_DIR/icon_512x512@2x.png" >/dev/null -iconutil -c icns "$ICONSET_DIR" -o "$APP_DIR/Contents/Resources/LiteLLMUsage.icns" -cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' - - - -CFBundleDisplayNameLiteLLM Usage -CFBundleExecutableLiteLLMUsageMenuBar -CFBundleIdentifiercom.litellm.usage-menubar -CFBundleInfoDictionaryVersion6.0 -CFBundleNameLiteLLM Usage -CFBundlePackageTypeAPPL -CFBundleShortVersionString0.1.0 -CFBundleVersion1 -CFBundleIconFileLiteLLMUsage.icns -LSMinimumSystemVersion13.0 -LSUIElement - -PLIST -SIGNING_IDENTITY="${CODESIGN_IDENTITY:-LiteLLM Usage Development}" -if ! security find-identity -v -p codesigning | grep -Fq "\"$SIGNING_IDENTITY\""; then - printf 'Missing valid codesigning identity: %s\n' "$SIGNING_IDENTITY" >&2 - exit 1 -fi -codesign --force --deep --timestamp=none --sign "$SIGNING_IDENTITY" "$APP_DIR" >/dev/null -rm -f "$DMG_PATH" -hdiutil create -volname "LiteLLM Usage" -srcfolder "$APP_DIR" -ov -format UDZO "$DMG_PATH" >/dev/null -printf '%s\n%s\n' "$APP_DIR" "$DMG_PATH" diff --git a/apps/macos-usage/scripts/fill-logo-background.swift b/apps/macos-usage/scripts/fill-logo-background.swift deleted file mode 100644 index ce2dd011762..00000000000 --- a/apps/macos-usage/scripts/fill-logo-background.swift +++ /dev/null @@ -1,104 +0,0 @@ -import CoreGraphics -import Foundation -import ImageIO -import UniformTypeIdentifiers - -guard CommandLine.arguments.count == 3 else { - fputs("usage: fill-logo-background.swift input.jpg output.png\n", stderr) - exit(2) -} - -let inputURL = URL(fileURLWithPath: CommandLine.arguments[1]) -let outputURL = URL(fileURLWithPath: CommandLine.arguments[2]) -let targetColor = (red: UInt8(135), green: UInt8(206), blue: UInt8(234)) - -guard let source = CGImageSourceCreateWithURL(inputURL as CFURL, nil), - let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - fputs("could not read logo\n", stderr) - exit(1) -} - -let imageWidth = image.width -let imageHeight = image.height -let canvasSize = max(imageWidth, imageHeight) -let scale = 1.16 -let scaledWidth = Int((Double(imageWidth) * scale).rounded()) -let scaledHeight = Int((Double(imageHeight) * scale).rounded()) -let origin = CGPoint(x: (canvasSize - scaledWidth) / 2, y: (canvasSize - scaledHeight) / 2) -let colorSpace = CGColorSpaceCreateDeviceRGB() -var pixels = [UInt8](repeating: 0, count: canvasSize * canvasSize * 4) - -guard let context = CGContext( - data: &pixels, - width: canvasSize, - height: canvasSize, - bitsPerComponent: 8, - bytesPerRow: canvasSize * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue -) else { - fputs("could not create image context\n", stderr) - exit(1) -} - -context.setFillColor(red: CGFloat(targetColor.red) / 255, green: CGFloat(targetColor.green) / 255, blue: CGFloat(targetColor.blue) / 255, alpha: 1) -context.fill(CGRect(x: 0, y: 0, width: canvasSize, height: canvasSize)) -context.draw(image, in: CGRect(origin: origin, size: CGSize(width: scaledWidth, height: scaledHeight))) - -func isBackgroundPixel(_ offset: Int) -> Bool { - let red = pixels[offset] - let green = pixels[offset + 1] - let blue = pixels[offset + 2] - let brightest = max(red, max(green, blue)) - let darkest = min(red, min(green, blue)) - return brightest >= 225 && brightest - darkest <= 24 -} - -var background = [Bool](repeating: false, count: canvasSize * canvasSize) -var queue: [Int] = [] - -func seed(_ index: Int) { - guard !background[index], isBackgroundPixel(index * 4) else { return } - background[index] = true - queue.append(index) -} - -for x in 0.. 0 { seed(index - 1) } - if x + 1 < canvasSize { seed(index + 1) } - if y > 0 { seed(index - canvasSize) } - if y + 1 < canvasSize { seed(index + canvasSize) } -} - -for index in queue { - let offset = index * 4 - pixels[offset] = targetColor.red - pixels[offset + 1] = targetColor.green - pixels[offset + 2] = targetColor.blue - pixels[offset + 3] = 255 -} - -guard let result = context.makeImage(), - let destination = CGImageDestinationCreateWithURL(outputURL as CFURL, UTType.png.identifier as CFString, 1, nil) else { - fputs("could not write logo\n", stderr) - exit(1) -} -CGImageDestinationAddImage(destination, result, nil) -guard CGImageDestinationFinalize(destination) else { - fputs("could not finalize logo\n", stderr) - exit(1) -}