id: llm-model-price-change-watch
namespace: company.team
description: |
Track the public OpenRouter model catalog every day, detect new models, retired
models, price changes and context-window changes for the providers you care about,
and post a short AI-written briefing to Slack that calls out the models you
actually run in production.
inputs:
- id: providers
type: ARRAY
itemType: STRING
displayName: Providers to watch
description: OpenRouter provider prefixes (the part of the model id before the slash).
defaults:
- openai
- anthropic
- google
- mistralai
- deepseek
- meta-llama
- qwen
- x-ai
- id: models_in_use
type: ARRAY
itemType: STRING
displayName: Models you use in production
description: Exact OpenRouter model ids. Any change to these is flagged as
needing action.
defaults:
- openai/gpt-5-mini
- anthropic/claude-haiku-4.5
- google/gemini-2.5-flash-lite
- id: price_change_threshold_pct
type: FLOAT
displayName: Price change threshold (%)
description: Ignore price moves smaller than this percentage, to avoid rounding noise.
defaults: 5.0
- id: include_variants
type: BOOL
displayName: Include variants
description: Also track ":free", ":batch" and similar variants and "~" aliases.
Off by default to keep the briefing short.
defaults: false
- id: model_name
type: STRING
displayName: Gemini model for the briefing
defaults: gemini-2.5-flash
tasks:
- id: download_catalog
type: io.kestra.plugin.core.http.Download
description: Download the public OpenRouter model catalog. No API key is needed.
uri: https://openrouter.ai/api/v1/models
headers:
User-Agent: Kestra-LLM-Price-Watch
- id: load_snapshot
type: io.kestra.plugin.core.kv.Get
description: Load the snapshot saved by the previous run. On the first run there
is none, so this run becomes the baseline.
key: llm_model_price_watch_snapshot
errorOnMissing: false
- id: diff_catalog
type: io.kestra.plugin.scripts.python.Script
description: Compare today's catalog with the previous snapshot and classify
every change. All numbers in the Slack message come from this step, not
from the LLM.
containerImage: python:3.12-slim
inputFiles:
catalog.json: "{{ outputs.download_catalog.uri }}"
previous.json: "{{ (outputs.load_snapshot.value ?? {}) | toJson }}"
script: |
import json
PROVIDERS = set({{ inputs.providers | toJson }})
IN_USE = set({{ inputs.models_in_use | toJson }})
THRESHOLD = float("{{ inputs.price_change_threshold_pct }}")
INCLUDE_VARIANTS = "{{ inputs.include_variants }}".lower() == "true"
def per_million(value):
# OpenRouter prices are USD per token, as strings. "-1" means variable pricing.
try:
price = float(value)
except (TypeError, ValueError):
return None
return None if price < 0 else round(price * 1_000_000, 4)
with open("catalog.json") as f:
models = json.load(f)["data"]
with open("previous.json") as f:
previous = json.load(f) or {}
if isinstance(previous, str):
previous = json.loads(previous)
current = {}
names = {}
for m in models:
model_id = m["id"]
if model_id.lstrip("~").split("/")[0] not in PROVIDERS:
continue
if not INCLUDE_VARIANTS and (model_id.startswith("~") or ":" in model_id):
continue
pricing = m.get("pricing") or {}
current[model_id] = {
"in": per_million(pricing.get("prompt")),
"out": per_million(pricing.get("completion")),
"ctx": m.get("context_length"),
}
names[model_id] = m.get("name", model_id)
def pct(old, new):
if old in (None, 0) or new is None:
return None
return round((new - old) / old * 100, 1)
added, removed, price_changes, context_changes = [], [], [], []
if previous:
for model_id in sorted(set(current) - set(previous)):
added.append({"id": model_id, "name": names[model_id], **current[model_id]})
for model_id in sorted(set(previous) - set(current)):
removed.append({"id": model_id, **previous[model_id]})
for model_id in sorted(set(current) & set(previous)):
old, new = previous[model_id], current[model_id]
for side in ("in", "out"):
change = pct(old.get(side), new.get(side))
if change is not None and abs(change) >= THRESHOLD:
price_changes.append({"id": model_id, "side": side, "old": old[side], "new": new[side], "pct": change})
if old.get("ctx") and new.get("ctx") and old["ctx"] != new["ctx"]:
context_changes.append({"id": model_id, "old": old["ctx"], "new": new["ctx"]})
touched = {c["id"] for c in removed + price_changes + context_changes}
in_use_alerts = sorted(touched & IN_USE)
missing_in_use = sorted(IN_USE - set(current))
change_count = len(added) + len(removed) + len(price_changes) + len(context_changes)
if not previous:
status = "BASELINE"
elif change_count:
status = "CHANGED"
else:
status = "UNCHANGED"
def money(v):
return "variable" if v is None else f"${v:g}"
lines = []
for c in price_changes:
side = "input" if c["side"] == "in" else "output"
arrow = "up" if c["pct"] > 0 else "down"
flag = " (IN USE)" if c["id"] in IN_USE else ""
lines.append(f"- `{c['id']}`{flag} {side} price {arrow} {abs(c['pct'])}%: {money(c['old'])} -> {money(c['new'])} per 1M tokens")
for c in added:
lines.append(f"- NEW `{c['id']}`: {money(c['in'])} in / {money(c['out'])} out per 1M tokens, {c['ctx']} token context")
for c in removed:
flag = " (IN USE)" if c["id"] in IN_USE else ""
lines.append(f"- REMOVED `{c['id']}`{flag}")
for c in context_changes:
lines.append(f"- `{c['id']}` context window {c['old']} -> {c['new']} tokens")
changes = {
"added": added,
"removed": removed,
"price_changes": price_changes,
"context_changes": context_changes,
"in_use_alerts": in_use_alerts,
}
print(f"{status}: watching {len(current)} models, {change_count} changes, in-use alerts: {in_use_alerts or 'none'}")
print("::" + json.dumps({"outputs": {
"status": status,
"watched_count": len(current),
"change_count": change_count,
"in_use_alerts": in_use_alerts,
"missing_in_use": missing_in_use,
"changes": changes,
"changes_markdown": "\n".join(lines[:40]) + ("\n- ...and more, see the execution outputs" if len(lines) > 40 else ""),
"snapshot": current,
}}) + "::")
- id: route_by_status
type: io.kestra.plugin.core.flow.Switch
description: First run stores a baseline, a changed catalog produces a briefing,
an unchanged catalog only logs.
value: "{{ outputs.diff_catalog.vars.status }}"
cases:
BASELINE:
- id: log_baseline
type: io.kestra.plugin.core.log.Log
message: "Baseline captured for {{ outputs.diff_catalog.vars.watched_count }}
models. Changes will be reported from the next run. In-use models
not found in the catalog: {{
outputs.diff_catalog.vars.missing_in_use }}"
CHANGED:
- id: write_briefing
type: io.kestra.plugin.ai.completion.ChatCompletion
description: Turn the structured diff into a short, decision-oriented briefing
for the engineering team.
provider:
type: io.kestra.plugin.ai.provider.GoogleGemini
apiKey: "{{ secret('GEMINI_API_KEY') }}"
modelName: "{{ inputs.model_name }}"
configuration:
temperature: 0.2
messages:
- type: SYSTEM
content: |
You write a short weekly-style briefing for an engineering team that pays for LLM APIs.
Use only the facts in the JSON you are given. Never invent prices, models or percentages.
Write plain Slack text, no headings, at most 6 bullet points, under 900 characters.
Start with the changes that affect the models the team uses in production.
For each relevant change, say what it means in practice (cost impact, a cheaper alternative
worth testing, or a migration that is needed). If nothing affects production models, say so first.
- type: USER
content: |
Models used in production: {{ inputs.models_in_use | toJson }}
In-use models affected: {{ outputs.diff_catalog.vars.in_use_alerts | toJson }}
In-use models missing from the catalog: {{ outputs.diff_catalog.vars.missing_in_use | toJson }}
Changes since the last run (prices are USD per 1M tokens, "in" = input, "out" = output):
{{ outputs.diff_catalog.vars.changes | toJson }}
- id: notify_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post the AI briefing followed by the exact list of changes computed
by the diff step.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: |
{{ outputs.diff_catalog.vars.in_use_alerts | length > 0 ? ':rotating_light: *LLM catalog change affects models you use*' : ':bar_chart: *LLM model catalog changed*' }} ({{ outputs.diff_catalog.vars.change_count }} changes across {{ inputs.providers | join(', ') }})
{{ outputs.write_briefing.textOutput }}
*Exact changes*
{{ outputs.diff_catalog.vars.changes_markdown }}
defaults:
- id: log_unchanged
type: io.kestra.plugin.core.log.Log
message: "No changes above {{ inputs.price_change_threshold_pct }}% across {{
outputs.diff_catalog.vars.watched_count }} watched models."
- id: save_snapshot
type: io.kestra.plugin.core.kv.Set
description: Save today's catalog as the next comparison point. It only runs
after the briefing was delivered, so a failed Slack post is retried with
the same diff next time.
key: llm_model_price_watch_snapshot
kvType: JSON
overwrite: true
value: "{{ outputs.diff_catalog.vars.snapshot | toJson }}"
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Make sure a broken watcher is noticed instead of looking like a
quiet catalog.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
messageText: ":warning: LLM model price watch failed in execution {{
execution.id }} ({{ flow.namespace }}.{{ flow.id }}). Check the OpenRouter
endpoint, GEMINI_API_KEY and SLACK_WEBHOOK_URL."
triggers:
- id: daily
type: io.kestra.plugin.core.trigger.Schedule
description: Check the catalog every morning. Disabled until the secrets are set.
cron: "0 7 * * *"
disabled: true
- id: on_demand
type: io.kestra.plugin.core.trigger.Webhook
description: Run a check on demand, for example from a chat command or before a
cost review.
key: llm-price-watch-change-me
outputs:
- id: status
type: STRING
value: "{{ outputs.diff_catalog.vars.status }}"
- id: change_count
type: INT
value: "{{ outputs.diff_catalog.vars.change_count }}"
- id: in_use_alerts
type: JSON
value: "{{ outputs.diff_catalog.vars.in_use_alerts | toJson }}"