How I Cut AI Token Usage in My Dynasty Fantasy Football Dashboard

My dynasty fantasy football dashboard started as a fairly traditional AI pipeline: collect player data, gather news and injuries, send every player to a language model, and display the resulting recommendations.

It worked—but it was doing far more AI work than necessary.

A healthy bench player with no recent news received essentially the same treatment as an injured starter whose role had materially changed. Players appearing in multiple leagues could generate repeated analysis. Even when nothing changed, the system risked paying to reach the same conclusion again.

I redesigned the pipeline around a simpler principle:

Python should manage facts and state. The language model should only handle decisions that genuinely require reasoning.

The result is a provider-independent system that makes fewer model calls, sends smaller prompts, produces less output, and caches results more safely.

The original architecture

The pipeline gathers data from several sources:

  • Sleeper rosters and player information
  • FantasyPros rankings
  • Dynasty trade values
  • Contract information
  • Player news and injury reports

Originally, the reasoning stage analysed individual players and returned a detailed object for each one:

{
  "trend": "UP",
  "confidence": "MEDIUM",
  "summary": "The key development affecting the player.",
  "fantasy_impact": "SHORT",
  "recommendation": "Hold and monitor usage.",
  "dynasty_note": "Long-term value remains stable.",
  "contract_note": "Under contract through 2027.",
  "roster_status_note": "Competing for the WR2 role.",
  "flags": ["depth_chart"]
}

That output is useful for an important player. It is wasteful for every player on every roster.

The system prompt, player metadata, contract details and news text all consumed input tokens. Requiring several generated fields per player also created a substantial output-token cost.

Moving from player analysis to league analysis

The biggest change was replacing per-player model calls with one compact request per materially changed league.

Instead of asking the model to analyse everybody, Python builds a league snapshot:

{
  "league": {
    "id": "123",
    "name": "The League",
    "season": "2026",
    "format": "dynasty"
  },
  "roster": [
    {
      "id": "9509",
      "n": "Example Player",
      "p": "RB",
      "age": 24,
      "role": "RB1",
      "starter": true,
      "ir": false,
      "taxi": false,
      "value": "Early 1st"
    }
  ]
}

Only players with a meaningful signal receive additional injury or news fields.

The model then returns a league overview and a short list of actionable exceptions:

{
  "overview": "The roster remains strong at running back but has two injury situations to monitor.",
  "actions": [
    {
      "player_id": "9509",
      "trend": "WATCH",
      "confidence": "MEDIUM",
      "action": "Hold and monitor practice participation.",
      "reason": "A new injury designation creates short-term uncertainty.",
      "flags": ["injury"]
    }
  ]
}

Stable players are omitted. Python supplies deterministic defaults for them, so they consume no output tokens.

The response is capped at eight actions and 900 output tokens per changed league.

Storing player facts once

Another improvement was separating global player facts from league-specific context.

The pipeline now maintains three layers:

player_store.json
    Canonical facts stored once per player

league_snapshots/
    Small league-specific roster and status records

league_analysis_cache.json
    Successful model analysis for each league

A player’s name, age, NFL team, contract, trade value, injury and news belong in the canonical player store.

Fields such as starter status, taxi status, IR status and roster designation belong in the league snapshot.

This prevents shared facts from being copied into every league’s stored data while still allowing recommendations to consider league-specific context.

Reducing news tokens

Scraped news can be surprisingly verbose. A single source may provide a headline, article body and separate analysis section. Multiple sites may report the same event using slightly different wording.

Sending all of that to the model is rarely useful.

The new pipeline:

  • Deduplicates news by normalized headline.
  • Includes at most two news events per player.
  • Caps headline length.
  • Excludes article bodies and generic commentary.
  • Adds news fields only to signal-bearing players.

The model generally needs the material fact, its source and its date—not several paragraphs of fantasy prose written by someone else.

Skipping quiet leagues

Before making a provider request, Python checks whether the league contains any material signals.

Signals include:

  • New player news
  • Injury designations
  • IR status
  • Taxi-squad status

If none exist, the model is skipped completely:

Skipping AI call for league=The League:
quiet league with no material signals

The dashboard receives deterministic text instead:

No material roster news or injury changes this cycle.

An empty roster also skips the provider.

This is an important distinction: prompt optimization makes calls cheaper, but avoiding unnecessary calls altogether is better.

Building a safer cache

Caching AI output sounds straightforward until failures enter the picture.

Each league analysis is fingerprinted using:

  • Selected provider
  • Selected model
  • Exact league payload

If all three are unchanged, the cached analysis is reused.

Including the provider and model is important. Switching from one model to another should produce a fresh analysis rather than silently serving text generated by the previous configuration.

I also found and fixed a more subtle failure mode.

An earlier implementation stored whatever analysis was displayed after a provider attempt—even an error fallback—under the new fingerprint. That meant a temporary outage could produce:

Analysis unavailable; showing current roster facts.

The fallback would then become a valid cache hit. Future runs with the same payload would skip the provider indefinitely.

The corrected rule is simple:

Only successful provider responses are written to the analysis cache.

If a request fails:

  • An older valid analysis may still be displayed.
  • The previous cache entry remains completely unchanged.
  • A fallback can be displayed when no prior result exists.
  • The next identical run retries the provider.

This prevents transient failures from poisoning the cache.

Supporting multiple AI providers

The reasoning layer originally depended directly on Anthropic. I replaced that assumption with a small provider adapter.

The provider is now selected through environment variables:

AI_PROVIDER=openai
OPENAI_API_KEY=...
OPENAI_MODEL=gpt-5-mini

Or:

AI_PROVIDER=anthropic
ANTHROPIC_API_KEY=...
ANTHROPIC_MODEL=claude-haiku-4-5

OpenAI is the default, but the rest of the pipeline does not know which provider is active. Both paths return the same validated internal structure.

Model selection follows a defined precedence:

  1. Provider-specific override
  2. Shared AI_MODEL override
  3. Legacy model setting
  4. Sensible provider default

This makes it possible to compare providers or change models without editing application code.

Making OpenAI responses more reliable

When I first tested the OpenAI Responses API, the request returned HTTP 200 but contained no visible JSON. The application then attempted to parse an empty string.

To make that path more reliable, I added:

  • Minimal reasoning effort
  • Low text verbosity
  • A strict JSON schema
  • Explicit empty-output detection
  • Diagnostics for incomplete responses
  • Reporting of response status and output item types

The strict schema ensures that both providers ultimately feed the same application contract, while minimal reasoning preserves more of the 900-token budget for visible output.

Logging what the pipeline is doing

Optimizing an AI pipeline is difficult if its decisions are invisible.

Before a real request, the pipeline now logs:

Calling AI provider=openai model=gpt-5-mini for league=The League

It also logs the exact JSON payload being sent.

Afterwards, it logs the final overview and its source:

Overall league analysis for league=The League source=model:
The roster is stable, with one injury situation worth monitoring.

Other possible sources include:

  • cache
  • quiet
  • empty_roster
  • stale_cache_after_error
  • error_fallback

This makes it easy to confirm whether displayed text came from a fresh model response, an earlier result or deterministic application logic.

Protecting the architecture with tests

Several of these improvements were vulnerable to being accidentally lost during merges, so I added regression tests around the behavior rather than relying on comments.

The tests verify that:

  • OpenAI is the default provider.
  • Anthropic remains selectable.
  • Provider-specific model overrides work.
  • Identical payloads reuse successful analysis.
  • Changing the model invalidates the cache.
  • Changing the provider invalidates the cache.
  • Failed requests are never cached.
  • An older valid cache entry survives a failure unchanged.
  • Quiet leagues make no provider call.
  • The orchestrator calls the active league reasoning agent.

The complete project suite now contains more than 150 passing tests.

The broader lesson

The most effective token optimization was not a shorter prompt. It was moving responsibility out of the prompt.

Python is better suited to:

  • Retrieving and normalizing data
  • Deduplicating news
  • Detecting changes
  • Managing cache fingerprints
  • Formatting known contract information
  • Producing defaults for quiet players

The model is most useful when it receives a small set of meaningful facts and answers a focused question:

Given this league and these material changes, what should the manager pay attention to?

That shift made the system cheaper, easier to inspect and less dependent on any particular provider. More importantly, it made the resulting dashboard more useful: instead of generating commentary for everybody, it highlights the decisions that may actually require action.

The GitHub repo is here: https://github.com/lukecampbell-cf/DynastyDashboard/

My Mum: Maggie

My mum was a real force of nature and passed away suddenly but peacefully recently.

I wanted to publish my eulogy in her memory.

It goes like this:


Mum.

Firstly, I can see so many familiar faces here today, including people I haven’t seen for years.

I know that seeing so many people here would have made her incredibly happy.

Friendship meant everything to her.

On behalf of Sarah, Rhiann and myself, and the rest of our family, thank you all for being here today, for your messages, your kindness, your support, and for those joining us online.

It has meant more to us than you’ll ever know.

—-

As you all know, my mum was an amazing human being.

My mum was amazing at many things, but she was especially great at solving problems.

Her 20 plus years as a theatre manager meant that her job involved managing actors and directors: creative people with very strong opinions. But she always treated people with kindness.

This was a lesson that I’ve taken with me – above all else, she taught me to do the right thing by people. Always. Without exception. And without ever stopping to think about the personal cost.

And If you ever asked my mum for advice, she never left it there. The next day there would be an email, a WhatsApp or a phone call because she’d thought of something else overnight that might help.

Her advice was always sensible, always kind, and almost always exactly what you needed to hear.

And it wasn’t just me. Or my sisters. Or My nieces for that matter.

Looking around the room, I can already see a few knowing smiles. I suspect many of you received exactly the same treatment.

She simply couldn’t stop helping people.

She would quite literally have given you the shirt off her back without a second thought.

That was the measure of her. Thoughtful. Loyal. Kind.

They are wonderful qualities.

On a slightly lighter note, though…The best story has to come from a trip we took together. I was lucky enough through work to spend some time in the Bahamas, and I managed to take Mum with me.

It was a hard life, honestly.

So one evening, after I’d finished “working” we were chatting and I was asking her how her day had been. She said:

“Son, it was lovely…but as a matter of fact, I met a gigolo today.”

I remember thinking, where on earth is this conversation going?

She said,

“He told me he specialised in taking older ladies somewhere they hadn’t been for a very long time.”

She continued:

“But – you know he seemed awfy confused when I said…what… Renfrew?”

After I’d finished laughing, I then asked:

“Well… was he at least good looking?”

She said,

“No… and he smelt really bad as well!”

That was my Mum.

Funny. Quick-witted. Great company. Always ready with the perfect comeback.

Many of my old colleagues knew her exactly that way.

And that’s what I’ll remember most.

My mum always told me she was proud of me.

But she never once asked whether I was proud of her.

Today, I’d like to answer that question with just three words.

Every single day.

Mum, I’ll be proud of you for the rest of my life.

And finally…

Mum…

I just want to tell you that I’m so glad that I came back from Australia to spend just a little

more time with you. Every day without you will feel like an eternity.

There is a hole in our hearts that I’m not sure will ever be filled.

I will miss you every single moment for the rest of my life, and I would give anything for just another few minutes with you 💔💔💔

I’d like to finish by reading the ending of a poem that my mum sent me as I was leaving to go to Australia, it broke my heart then, and it’s even more poignant now:

“I miss you with every beat of my heart.

Oh I always did”

Mum – I will miss you with every beat of my heart – and I always will.