Skip to content

feat(pricing): add OpenRouter as second fallback pricing source - #1225

Open
godlockin wants to merge 17 commits into
kenn-io:mainfrom
godlockin:feat/openrouter-pricing-source
Open

feat(pricing): add OpenRouter as second fallback pricing source#1225
godlockin wants to merge 17 commits into
kenn-io:mainfrom
godlockin:feat/openrouter-pricing-source

Conversation

@godlockin

@godlockin godlockin commented Jul 22, 2026

Copy link
Copy Markdown

Adds OpenRouter’s public model catalog as a secondary pricing source while keeping LiteLLM first for overlapping rates. Fresh servers seed the embedded fallback synchronously, then refresh both catalogs in the background at startup and every 24 hours. Custom model overrides remain authoritative without periodic concurrent reassignment.

OpenRouter text-output models, including free models, are converted to per-million-token rates. Unique bare aliases are tracked and reconciled when they become ambiguous, with the same deletion behavior propagated by PostgreSQL push sync.

Updates pricing and privacy documentation to describe source priority, fallback behavior, and outbound catalog requests.

@roborev-ci

roborev-ci Bot commented Jul 22, 2026

Copy link
Copy Markdown

roborev: Combined Review (ff5750b)

The pricing refresh changes introduce five medium-severity correctness and concurrency regressions.

Medium

  • cmd/agentsview/main.go:299 — Removing seedPricing(database) leaves fresh databases without fallback pricing because the periodic loop waits 24 hours before its first refresh. Seed synchronously and trigger the initial background refresh before starting the periodic loop.

  • internal/pricing/litellm.go:89MergePricing iterates over a map, making LiteLLM-first precedence nondeterministic and TestMergePricing_FirstNonZeroWins flaky. Merge an ordered source slice in explicit priority order.

  • internal/pricing/catalog/openrouter.go:114, cmd/agentsview/usage.go:498 — Refreshes only upsert, so obsolete bare aliases remain in the database when a suffix becomes ambiguous and can continue pricing the wrong model. Track and reconcile OpenRouter aliases during refresh, or derive them dynamically from the current catalog.

  • internal/pricing/catalog/openrouter.go:160parsePricePerToken rejects zero, although zero is valid for free models. This omits free models and prevents stored nonzero prices from being updated to free. Accept zero while rejecting negative and malformed values.

  • cmd/agentsview/usage.go:419 — The periodic goroutine calls SetCustomPricing concurrently with unsynchronized request-handler reads of customPricing, creating a data race. Remove the unnecessary periodic reassignment or synchronize access with a mutex.


Reviewers: 2 done | Synthesis: codex, 12s | Total: 4m33s

@roborev-ci

roborev-ci Bot commented Jul 23, 2026

Copy link
Copy Markdown

roborev: Combined Review (be88058)

Medium findings:

  • cmd/agentsview/usage.go:473 — When LiteLLM fails but OpenRouter succeeds, cached higher-priority LiteLLM rates are omitted from the merge and may be overwritten by overlapping OpenRouter rates. Preserve cached contributions from failed sources, or allow lower-priority sources to fill only missing patterns. Add an overlapping-model partial-failure test.

  • internal/pricing/litellm.go:121MergePricing treats zero as absent even though explicit zero-cost rates are valid. This can replace a free LiteLLM rate with a nonzero OpenRouter rate, violating precedence and overcharging usage. Track field presence or make the higher-priority row authoritative, and test explicit-zero overlaps.

  • cmd/agentsview/usage.go:546 — Alias reconciliation and _openrouter_aliases provenance metadata are written in separate transactions. A crash or failure between them can leave stale aliases that cannot later be identified, while PostgreSQL push may observe inconsistent state. Write alias changes and metadata atomically in one transaction.

  • internal/pricing/catalog/openrouter.go:41 — The automatic OpenRouter refresh uses an unbounded io.ReadAll, allowing a malicious or compromised upstream response, including a decompressed gzip bomb, to exhaust process memory. Apply a reasonable decompressed-size limit and reject oversized responses.


Reviewers: 2 done | Synthesis: codex, 11s | Total: 7m21s

godlockin and others added 13 commits July 23, 2026 23:24
The seed-and-refresh loop in cmd/agentsview previously only
talked to the LiteLLM pricing catalog. When the upstream fetch
failed (offline, DNS broken, rate-limited) and the embedded
fallback snapshot did not contain the user's model, daily
usage cost silently dropped to $0 — the same symptom that the
fork model custom-pricing test guards against.

Wire in OpenRouter's public /models endpoint as a second
background source. LiteLLM stays first because it covers the
public models agentsview normally parses; OpenRouter fills in
fork-tuned and private model prices LiteLLM has not yet
picked up. Each fetch failure is logged but never aborts the
loop, so a partial outage of one upstream does not prevent the
other from seeding. All successful results are merged with
first-non-zero precedence per model_pattern.

Adds:
- internal/pricing/catalog/openrouter.go: fetcher and parser
- internal/pricing/openrouter_test.go: unit tests for parser
  filtering, per-token-to-per-million conversion, and merge
  precedence
- DefaultPricingSources() and MergePricing() in litellm.go
  exposing the source list and the merge helper so other
  callers (CLI statusline, future config-driven sources) can
  reuse them
- refreshPricingFromSources() in cmd/agentsview/usage.go
  replacing the previous single-source call

Also bumps the default-port assertion in
cmd/agentsview/main_test.go and pg_test.go from 8080 to 9765
to match the port change landed earlier.
…aths

Add two regression tests around GetDailyUsage so future changes
that silently drop unpriced models can be caught immediately.

TestGetDailyUsageForkModelPricing inserts a custom model pattern
("internal-private-model") via UpsertModelPricing and verifies
that the day entry exists with the expected input/output token
counts and a non-zero cost. This is the case the user hit in
June 2026: a downstream fork that uses internal/private model
identifiers saw $0.00 cost because the model name did not
canonicalize to any upstream LiteLLM catalog key, even though
the database had rows with valid token_usage payloads.

TestGetDailyUsageUnknownModelHasZeroCostButCountsTokens covers
the fall-through case where a model is genuinely not priced:
the day entry must still exist (tokens are still counted), but
cost is $0. If this test ever fails with len(Daily)==0 the
upstream time-window SQL from issue kenn-io#904 has regressed.
seedPricing already kicks off one LiteLLM + OpenRouter fetch at
startup and reapplies custom_model_pricing on top. But newly
released or repriced upstream models were never picked up
without a restart, which meant the dashboard could show stale
rates for weeks between agentsview upgrades.

periodicPricingRefresh runs a ticker in the server goroutine
for the whole lifetime of the process. Every tick it reruns
refreshPricingFromSources (LiteLLM merged with OpenRouter) and
reapplies cfg.CustomModelPricing so a newly-published upstream
rate cannot silently shadow the user's own override for
fork/private model names.

The loop is a no-op when interval <= 0 and unwinds on context
cancel. Interval defaults to 24h — long enough to be gentle on
the upstream catalogs, short enough that a mid-week price drop
is picked up the next day.
The previous filter required strict text->text modality, which
dropped multimodal-input, text-output models like MiniMax-M3
(text+image+video->text) and kimi-k2.5 (text+image->text) — the
very models users reach via bare names such as `MiniMax-M3`. They
still bill prompt/completion in text tokens, so filter on the
output side only. Combined with the unqualified-suffix alias,
sessions that log bare model names now resolve pricing directly
from OpenRouter's public catalog.
OpenRouter ids are provider-qualified (`minimax/minimax-m3`), but
agentsview sessions frequently record bare model names
(`MiniMax-M3`, `kimi-k2.5`). The canonical resolver refused those
lookups because every candidate key had a provider prefix and the
same rank, so multiple providers tied and the row stayed unpriced.

When a bare suffix is unique across the OpenRouter catalog, also
emit an unqualified ModelPricing row so the resolver can rank it at
the unqualified tier and resolve a bare user-side model. Shared
suffixes (two providers publishing `kimi-k2.5`) still only produce
prefixed rows to avoid fabricating OpenRouter-internal ambiguity.
cmd/agentsview/seedPricing writes the LiteLLM fallback
snapshot to model_pricing and fires a background multi-source
refresh, but it never wired the config-driven
[custom_model_pricing] map into the running DB. The CLI
statusline and pg serve paths call applyCustomPricing
explicitly, which masked the gap, but the embedded HTTP
server left db.customPricing at its zero value and silently
priced fork-private models at \$0.

Call applyCustomPricing immediately after seedPricing so
fork owners can configure their internal-model rates in
config.toml and have them flow into every GetDailyUsage
call without a CLI detour.

The read path was already correct: loadPricingMap merges
db.customPricing on top of whatever model_pricing returns, so
this fix is purely about plumbing the writer.
Cherry-pick bookkeeping for the upcoming upstream PR:

- main_test.go, pg_test.go: restore 8080 default (the c876288
  fork commit bundled in a 9765 port change that is not part of
  this PR)
- usage_test.go: drop the TestRefreshPricingIfStale_* /
  TestEnsurePricingWithFetcher* tests that reference fork-local
  helpers (refreshPricingIfStale, ensurePricingWithFetcher) that
  do not exist on upstream; the upstream equivalent lives in the
  pricingrefresh package and is tested there
- usage.go: add a local upsertPricing helper used by
  seedFallbackPricing and refreshPricingFromSources. The same
  helper exists inside internal/pricingrefresh on upstream but
  is unexported, so we duplicate the few lines here to keep the
  PR self-contained
- openrouter.go, litellm.go: gofmt -s alignment cleanup
- main.go: drop a stray seedPricing line left over from the
  79f862b conflict resolution (applyCustomPricing is the only
  startup hook on this path on upstream)

No functional change to the OpenRouter fetcher, the 24h refresh
loop, or the parser updates.
Restore synchronous startup fallback seeding while keeping the initial network refresh asynchronous, and remove the periodic custom-pricing map write that raced with request reads.

Track OpenRouter aliases so refreshes can remove obsolete bare names locally and in PostgreSQL, preserve ordered LiteLLM precedence, and accept free-model zero prices.

VALID (fixed): #1, #2, #3, kenn-io#4, kenn-io#5
INVALID (dismissed): none
PEDANTIC (skipped): none
MergePricing treated a zero rate from the higher-priority source as
missing and filled it from a later catalog. Zero is valid pricing for
free models, and ModelPricing cannot distinguish an explicit zero from
an absent field, so a lower-priority nonzero row could silently turn a
free model into a paid one. The first source to declare a pattern now
owns the row entirely; later sources only contribute new patterns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReconcileModelPricing and SetPricingMeta were separate writes, so a
crash or a concurrent pg push between them could observe OpenRouter
alias rows without the _openrouter_aliases provenance metadata,
leaving obsolete aliases unretireable on later refreshes. The
metadata sentinel now commits inside the reconciliation transaction
(matching the PostgreSQL side, where the meta row already travels in
the same reconcile transaction).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenRouter bare aliases were generated from suffix uniqueness within
the OpenRouter catalog alone, but an unqualified pricing key outranks
a provider-qualified one during resolution. An alias like minimax-m3
therefore shadowed LiteLLM's minimax/MiniMax-M3 row for bare session
model names, inverting the documented LiteLLM-first precedence.

Alias rows are now dropped at refresh time when a higher-priority
source already covers the same canonical model name (via a qualified
row or an exact bare row); the stored alias provenance metadata
reflects the suppressed set so stale aliases still retire cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pgtest-tagged integration test still called ReconcileModelPricing
with the old two-argument signature, breaking compilation for the
pgtest suite. Pass the alias metadata through the reconciliation call,
which also exercises the new atomic metadata write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Alias suppression only sees sources that succeeded in the current
refresh, so a LiteLLM outage let OpenRouter aliases be written
unsuppressed and shadow persisted LiteLLM rows for bare model names.

When any higher-priority fetch fails, the refresh now drops all alias
rows from the OpenRouter batch and leaves the stored alias set and its
provenance metadata untouched: qualified rows still refresh, and alias
additions or retirements wait for a fully successful refresh that can
re-validate them. This also avoids alias flapping across transient
outages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mjacobs
mjacobs force-pushed the feat/openrouter-pricing-source branch from be88058 to 613dd38 Compare July 24, 2026 17:22
@roborev-ci

roborev-ci Bot commented Jul 24, 2026

Copy link
Copy Markdown

roborev: Combined Review (613dd38)

The pricing refresh is generally sound, but one medium-severity resolver ambiguity can cause valid lookups to return zero cost.

Medium

  • internal/pricing/litellm.go:112 — Canonically colliding provider-qualified rows become ambiguous. Shadowed OpenRouter aliases are removed, but their provider-qualified rows remain. For example, LiteLLM’s minimax/MiniMax-M3 and OpenRouter’s minimax/minimax-m3 canonicalize identically, so a bare MiniMax-M3 lookup encounters two equal-rank qualified rows and is rejected as ambiguous instead of using LiteLLM’s higher-priority rate.

    Suggested fix: Exclude lower-priority qualified rows that canonically collide with an earlier source, or incorporate source priority into resolution. Add a resolver-level test covering overlapping qualified rows.


Reviewers: 2 done | Synthesis: codex, 10s | Total: 6m55s

mjacobs added 3 commits July 24, 2026 10:50
Alias suppression removed OpenRouter's bare alias rows when a
higher-priority source already covered the model, but left the
provider-qualified row in place. LiteLLM's minimax/MiniMax-M3 and
OpenRouter's minimax/minimax-m3 canonicalize identically and rank
equally, so a bare MiniMax-M3 lookup found two tied keys, was rejected
as ambiguous, and priced the model at zero.

SuppressShadowedOpenRouterRows replaces SuppressShadowedOpenRouterAliases
and generalizes the rule: an unqualified OpenRouter row is shadowed by
any earlier row with the same canonical name, and a qualified row is
shadowed by an earlier row with the same canonical provider and name.
Canonical collisions across different providers stay, since they are
distinct vendor listings and dropping one would leave its own qualified
id unpriced.

The function now reports the dropped patterns, and the refresh adds them
to the reconciliation removal list. A row stored by an earlier refresh,
while the higher-priority source still lacked the model, is retired
instead of lingering and keeping the lookup ambiguous.
Suppressed OpenRouter rows were deleted from SQLite but survived in
PostgreSQL. pricingSyncChanges derives removals only from the
_openrouter_aliases diff, and a shadowed qualified row such as
minimax/minimax-m3 is not an alias, so pg push left it in place. pg
serve then kept resolving the bare model name against two canonically
identical rows, treating it as ambiguous and pricing it at zero.

A second sentinel, _openrouter_shadowed, records the patterns each
refresh suppressed. Unlike the alias list, which push targets diff
against their own copy to find retired aliases, the shadowed list is
absolute: every pattern on it must not exist, so PostgreSQL retires it
without tracking history. Deletion still requires provenance rather than
a set difference, because a pattern PostgreSQL holds and the local
archive lacks is otherwise indistinguishable from one another machine
pushed.

ReconcileModelPricing now takes a variadic set of PricingMeta entries so
both sentinels commit in the same transaction as the rows they describe.
@roborev-ci

roborev-ci Bot commented Jul 24, 2026

Copy link
Copy Markdown

roborev: Combined Review (9515861)

Changes requested: one medium-severity pricing catalog reconciliation issue remains.

Medium

  • cmd/agentsview/usage.go:486 — A partial source outage does not preserve the last reconciled catalog. If LiteLLM fails, the code removes only OpenRouter aliases and blindly upserts qualified OpenRouter rows. This can restore previously shadowed rows (for example, minimax/minimax-m3 alongside persisted minimax/MiniMax-M3), making bare-name resolution ambiguous and reducing cost to zero. Exact overlaps may also overwrite persisted higher-priority LiteLLM rates.

    Suggested fix: Make partial refreshes non-mutating, or persist complete source ownership and reconcile successful lower-priority rows against stored higher-priority rows and shadow metadata. Add a regression test for a previously shadowed qualified row during a LiteLLM outage.


Reviewers: 2 done | Synthesis: codex, 11s | Total: 6m59s

@roborev-ci

roborev-ci Bot commented Jul 24, 2026

Copy link
Copy Markdown

roborev: Combined Review (82da5ca)

Review verdict: Three medium-severity pricing synchronization issues should be addressed before merge.

Medium

  • internal/pricing/catalog/openrouter.go:52 — The parser ignores OpenRouter’s conditional pricing.overrides, treating long-context and time-dependent rates as universal. This can materially misprice usage and cause historical totals to vary with refresh timing. Model conditional pricing during usage calculation, or skip entries with unsupported overrides until they can be represented correctly.

  • cmd/agentsview/usage.go:582_openrouter_shadowed retains only rows suppressed during the current refresh. If PostgreSQL or DuckDB misses that refresh and the model disappears before the next one, the tombstone is replaced with [], allowing an obsolete remote row to persist and potentially causing ambiguous model resolution or zero cost. Persist durable row ownership or tombstones, and test delayed synchronization across two refreshes.

  • cmd/agentsview/usage.go:475 — A successful fetch returning zero rows is accepted as a complete snapshot. An empty catalog or changed JSON envelope could therefore delete all OpenRouter aliases and allow lower-priority pricing to replace LiteLLM rates for up to 24 hours. Validate that built-in catalog responses are structurally complete and plausibly non-empty; otherwise treat them as fetch failures.


Reviewers: 2 done | Synthesis: codex, 9s | Total: 6m15s

@roborev-ci

roborev-ci Bot commented Jul 24, 2026

Copy link
Copy Markdown

roborev: Combined Review (82da5ca)

The pricing synchronization changes need fixes for two medium-severity data-consistency risks.

Medium

  • internal/db/pricing_sync.go:43 — PostgreSQL alias provenance is not machine/version scoped. A machine using an older 24-hour catalog snapshot can compare stale aliases against newer PostgreSQL metadata, restore retired aliases, and overwrite that metadata, causing pricing to oscillate until all machines refresh. Attach a monotonic catalog timestamp/version and reconcile only from newer snapshots, or track provenance per machine and derive deletions without last-writer-wins metadata.

  • cmd/agentsview/usage.go:484 — Empty catalog responses are treated as complete snapshots. Both parsers accept structurally valid empty responses, so a transient 200 {} can retire every OpenRouter alias and propagate the incomplete state to PostgreSQL and DuckDB for up to 24 hours. Require a plausible non-empty result from default catalog fetches; treat empty or missing catalog data as a source failure and preserve the previous snapshot.


Reviewers: 2 done | Synthesis: codex, 7s | Total: 43m5s

@mjacobs

mjacobs commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

So, this is not converging cleanly enough to keep patching finding-by-finding: new states are emerging faster than reviews/fixes are closing them.

Assessment from Codex:

I would keep the fetch/parser work but reset the reconciliation layer:

  • Bound and validate both upstream responses.
  • Reject structurally invalid or implausibly empty snapshots.
  • Skip OpenRouter entries with unsupported overrides for now.
  • Replace alias plus transient-shadow metadata with one complete source-ownership manifest—or explicit source ownership per row—so every backend can derive deletions from its last observed snapshot without seeing every intermediate refresh.
    That is a contained architectural correction, not a wholesale PR rewrite. After that, one review should cover a much smaller state space. Continuing with another narrow tombstone patch is likely to produce another round.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants