mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(model-picker): no "Free" tag on subscription-billed rows for window plans
Rows the gateway bills to a subscription the account holds (billing_mode: subscription) keep their free usage level, so a full allowance never dims them, but the pickers no longer label them "Free": that subscription pays. The ids ride on plan_usage.subscription_model_ids for Desktop/TUI; CLI and messaging pickers use usage_tag_levels().
This commit is contained in:
1 parent
9e8973110f
commit
384e4bde56
11 files changed
+166
-7
No files matched your search
@@ -106,4 +106,30 @@ describe('keepWarmNousRow', () => {
|
||||
expect(row?.usage_levels).toEqual(warm.providers?.[0].usage_levels)
|
||||
expect(row?.unavailable_models).toEqual([])
|
||||
})
|
||||
|
||||
it("keeps the warm subscription-billed ids (no Free tag) on the pending row's fresh plan_usage", () => {
|
||||
const gauges = [{ label: 'day', left_fraction: 0.5, style: 'bar' }]
|
||||
const fresh = [{ label: 'day', left_fraction: 0.2, style: 'bar' }]
|
||||
|
||||
const warmSubscribed: ModelOptionsResult = {
|
||||
providers: [
|
||||
{
|
||||
...warm.providers![0],
|
||||
plan_usage: { gauges, kind: 'usage_window', subscription_model_ids: ['example/model-sub'] },
|
||||
usage_levels: { ...warm.providers![0].usage_levels, 'example/model-sub': 'free' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const pendingSubscribed: ModelOptionsResult = {
|
||||
providers: [{ ...pending.providers![0], plan_usage: { gauges: fresh, kind: 'usage_window' } }]
|
||||
}
|
||||
|
||||
const row = (keepWarmNousRow(warmSubscribed, pendingSubscribed) as ModelOptionsResult).providers?.[0]
|
||||
|
||||
expect(row?.usage_levels?.['example/model-sub']).toBe('free')
|
||||
expect(row?.plan_usage?.subscription_model_ids).toEqual(['example/model-sub'])
|
||||
// The gauges are the pending row's own (fresh), not the warm row's.
|
||||
expect(row?.plan_usage?.gauges).toEqual(fresh)
|
||||
})
|
||||
})
|
||||
@@ -51,6 +51,14 @@ export function keepWarmNousRow(oldData: unknown, newData: unknown): unknown {
|
||||
kept[field] = warm[field]
|
||||
}
|
||||
|
||||
// The kept `usage_levels` still say `free` for subscription-billed rows; the ids
|
||||
// that keep the "Free" tag off them ride on `plan_usage` (whose gauges stay fresh), so carry them too.
|
||||
const subscribed = warm.plan_usage?.subscription_model_ids
|
||||
|
||||
if (row.plan_usage && subscribed?.length) {
|
||||
kept.plan_usage = { ...row.plan_usage, subscription_model_ids: subscribed }
|
||||
}
|
||||
|
||||
return { ...row, ...kept } as ModelOptionProvider
|
||||
})
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface PlanUsage {
|
||||
plan?: string | null
|
||||
gauges?: PlanUsageGauge[]
|
||||
top_tier_model_ids?: string[]
|
||||
subscription_model_ids?: string[]
|
||||
featured_model_ids?: string[]
|
||||
paused_reason?: string | null
|
||||
credits_pay?: boolean | null
|
||||
|
||||
@@ -27101,6 +27101,13 @@
|
||||
"title": "Top Tier Model Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"subscription_model_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": "Subscription Model Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"featured_model_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
|
||||
@@ -31,10 +31,16 @@ export function usageLevel(provider: PlanProvider, model: string): UsageLevel |
|
||||
}
|
||||
|
||||
/** The tag a picker row shows: only Large (it has its own monthly limit) and Free (never runs out). Small /
|
||||
* Medium change only how fast usage drains, so they carry no tag. */
|
||||
* Medium change only how fast usage drains, so they carry no tag. A row billed to a subscription the account
|
||||
* holds (`plan_usage.subscription_model_ids`) keeps its `free` level, so it is never dimmed, but carries no
|
||||
* tag: "Free" would read as free of charge when that subscription pays. */
|
||||
export function usageTag(provider: PlanProvider, model: string): 'free' | 'top_tier' | null {
|
||||
const level = usageLevel(provider, model)
|
||||
|
||||
if (level && (provider?.plan_usage?.subscription_model_ids ?? []).includes(model)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return level === 'free' || level === 'top_tier' ? level : null
|
||||
}
|
||||
|
||||
|
||||
@@ -80,8 +80,9 @@ class _ModelPickerRows:
|
||||
usage = usage.with_pricing(pricing)
|
||||
self.usage = usage
|
||||
if self.window_mode:
|
||||
from hermes_cli.model_picker_usage import usage_levels
|
||||
self.levels = usage_levels(all_models, pricing)
|
||||
from hermes_cli.model_picker_usage import usage_tag_levels
|
||||
# Tags only: dimming reads ``usage.blocks`` (free_ids), so subscription-billed rows still never dim.
|
||||
self.levels = usage_tag_levels(all_models, pricing)
|
||||
self.has_pricing = False
|
||||
self.has_cache = False
|
||||
self.any_on_sale = False
|
||||
|
||||
@@ -88,6 +88,10 @@ class PickerUsage:
|
||||
# Window only: model ids that draw on nothing (the gateway's ``free`` word or a free catalog row). A full
|
||||
# allowance never blocks them. Empty until applied.
|
||||
free_ids: frozenset[str] = frozenset()
|
||||
# Window only: model ids the gateway bills to a subscription the account holds (``billing_mode:
|
||||
# subscription``). They stay in ``free_ids`` (they draw on nothing, so nothing dims them); this set only
|
||||
# keeps the word "Free" off them, since that subscription pays. Empty until applied.
|
||||
subscription_ids: frozenset[str] = frozenset()
|
||||
# Window only: past a full allowance, does the turn bill top-up credits (pay as you go)? True: "Keep
|
||||
# going on credits" is on and there is a top-up balance. False: it stops. None: unknown (older portal).
|
||||
credits_pay: Optional[bool] = None
|
||||
@@ -106,8 +110,9 @@ class PickerUsage:
|
||||
levels = usage_levels(list(pricing), pricing) # same rule as the tags and agent.plan_limits.is_top_tier
|
||||
free = frozenset(m for m, level in levels.items() if level == "free")
|
||||
marked = frozenset(m for m, level in levels.items() if level == "top_tier")
|
||||
subscribed = frozenset(m for m in pricing if is_subscription_billed_row(pricing.get(m)))
|
||||
from dataclasses import replace
|
||||
return replace(self, top_tier_ids=marked, free_ids=free)
|
||||
return replace(self, top_tier_ids=marked, free_ids=free, subscription_ids=subscribed)
|
||||
|
||||
def exhausted_gauge(self, model_id: str) -> Optional[UsageGauge]:
|
||||
"""Window only: the spent allowance that stops this model (an all-scope one, or the top-tier one for a
|
||||
@@ -187,6 +192,9 @@ class PickerUsage:
|
||||
],
|
||||
# The gateway's marks (what the desktop's used-up notes read).
|
||||
"top_tier_model_ids": sorted(self.top_tier_ids),
|
||||
# Subscription-billed: still ``free`` in ``usage_levels`` (never dimmed), but
|
||||
# pickers show no "Free" tag on them (``plan-usage.ts`` ``usageTag``).
|
||||
"subscription_model_ids": sorted(self.subscription_ids),
|
||||
"featured_model_ids": list(self.featured_model_ids),
|
||||
"paused_reason": self.paused_reason,
|
||||
"credits_pay": self.credits_pay,
|
||||
@@ -260,6 +268,21 @@ def usage_levels(model_ids: list[str], pricing: Optional[dict[str, dict[str, Any
|
||||
return out
|
||||
|
||||
|
||||
def is_subscription_billed_row(pricing_row: Any) -> bool:
|
||||
"""The gateway bills this catalog row to a subscription the account holds (``billing_mode: subscription``)."""
|
||||
from hermes_cli.models import _is_subscription_billed
|
||||
return _is_subscription_billed(pricing_row)
|
||||
|
||||
|
||||
def usage_tag_levels(model_ids: list[str], pricing: Optional[dict[str, dict[str, Any]]]) -> dict[str, str]:
|
||||
"""The levels a picker row shows a tag for: :func:`usage_levels` without the subscription-billed rows.
|
||||
Those still draw nothing from the window (``usage_levels`` keeps them ``free``, which is what keeps a full
|
||||
allowance from dimming them), but "Free" would read as free of charge when that subscription pays. The
|
||||
gateway gives them no plan label either. Display only."""
|
||||
levels = usage_levels(model_ids, pricing)
|
||||
return {m: lv for m, lv in levels.items() if not is_subscription_billed_row((pricing or {}).get(m))}
|
||||
|
||||
|
||||
def is_free_model(pricing_row: Optional[dict[str, Any]]) -> bool:
|
||||
"""True when the catalog lists the model at $0 in and $0 out (``:free`` / natively free). A row
|
||||
with no pricing is NOT free — unknown price is not a promise."""
|
||||
@@ -448,7 +471,11 @@ def window_row_labels(provider_row: Any, model_ids: list) -> list[str]:
|
||||
ids = [str(m) for m in model_ids]
|
||||
plan = provider_row.get("plan_usage") if isinstance(provider_row, dict) else None
|
||||
levels = provider_row.get("usage_levels") if isinstance(plan, dict) and plan.get("kind") == "usage_window" else None
|
||||
tags = [usage_level_label(levels.get(m)) for m in ids] if isinstance(levels, dict) else []
|
||||
# Subscription-billed rows keep their ``free`` level but carry no tag.
|
||||
subscribed = (set(plan.get("subscription_model_ids") or ())
|
||||
if isinstance(plan, dict) and isinstance(levels, dict) else set())
|
||||
tags = ([usage_level_label(None if m in subscribed else levels.get(m)) for m in ids]
|
||||
if isinstance(levels, dict) else [])
|
||||
if not any(tags):
|
||||
return ids
|
||||
width = min(max(len(m) for m in ids), 48)
|
||||
|
||||
@@ -1421,7 +1421,7 @@ def _attach_window_model_notes(providers: list[dict]) -> None:
|
||||
nous = next((p for p in providers if str(p.get("slug", "")).lower() == "nous"), None)
|
||||
if nous is None:
|
||||
return
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage, usage_level_label, usage_levels
|
||||
from hermes_cli.model_picker_usage import nous_picker_usage, usage_level_label, usage_tag_levels
|
||||
fresh = nous_picker_usage()
|
||||
usage = fresh or nous_picker_usage(any_age=True)
|
||||
if usage is None:
|
||||
@@ -1429,7 +1429,8 @@ def _attach_window_model_notes(providers: list[dict]) -> None:
|
||||
from agent.plan_limits import cached_pricing
|
||||
pricing = cached_pricing() # this profile's catalog only (cache-only, no credential refresh)
|
||||
models = [str(m) for m in nous.get("models", [])]
|
||||
labels = {m: label for m, lv in usage_levels(models, pricing).items() if (label := usage_level_label(lv))}
|
||||
# Subscription-billed rows carry no tag (usage_tag_levels).
|
||||
labels = {m: label for m, lv in usage_tag_levels(models, pricing).items() if (label := usage_level_label(lv))}
|
||||
if labels:
|
||||
nous["model_labels"] = labels
|
||||
if fresh is None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from hermes_cli.model_picker_usage import (
|
||||
plan_gauge_lines,
|
||||
render_bar,
|
||||
usage_levels,
|
||||
usage_tag_levels,
|
||||
)
|
||||
|
||||
|
||||
@@ -567,3 +568,68 @@ def test_window_plan_feeds_the_existing_usage_chip(monkeypatch):
|
||||
other: dict = {"slug": "nous"}
|
||||
inventory._attach_nous_plan_usage(other, warm=False)
|
||||
assert other == {"slug": "nous"}
|
||||
|
||||
|
||||
# ── subscription-billed rows ────────────────────────────────
|
||||
|
||||
# Picker pricing shape. The gateway marks a subscription-billed row ``billing_mode: subscription`` and gives it no
|
||||
# ``usage_level``; its listed price never applies.
|
||||
_SUBSCRIPTION_BILLED = {
|
||||
"example/model-sub": {"prompt": "0.000005", "completion": "0.00004", "billing_mode": "subscription"},
|
||||
"gift/m:free": {"prompt": "0", "completion": "0"},
|
||||
"example/model-b": {"prompt": "0.000005", "completion": "0.000025", "usage_level": "top_tier", "top_tier": True},
|
||||
}
|
||||
|
||||
|
||||
def test_subscription_billed_rows_draw_nothing_but_carry_no_free_tag():
|
||||
# Still ``free`` to the window: nothing dims it, and the payload level the desktop/TUI dim from stays.
|
||||
assert usage_levels(list(_SUBSCRIPTION_BILLED), _SUBSCRIPTION_BILLED)["example/model-sub"] == "free"
|
||||
# The tag set drops it; real $0 rows and Large rows keep theirs.
|
||||
assert usage_tag_levels(list(_SUBSCRIPTION_BILLED), _SUBSCRIPTION_BILLED) == {
|
||||
"gift/m:free": "free", "example/model-b": "top_tier"}
|
||||
usage = _window_usage(day_used_bp=10_000, top_tier_used_bp=10_000).with_pricing(_SUBSCRIPTION_BILLED)
|
||||
assert not usage.blocks("example/model-sub")
|
||||
assert not usage.blocks("gift/m:free")
|
||||
assert usage.blocks("example/model-b")
|
||||
assert usage.to_payload()["subscription_model_ids"] == ["example/model-sub"]
|
||||
# No catalog: nothing marked.
|
||||
assert _window_usage().to_payload()["subscription_model_ids"] == []
|
||||
|
||||
|
||||
def test_window_picker_rows_leave_subscription_billed_rows_untagged():
|
||||
rows = _ModelPickerRows(list(_SUBSCRIPTION_BILLED), _SUBSCRIPTION_BILLED, current_model="", sale_chrome=True,
|
||||
usage=_window_usage(day_used_bp=10_000))
|
||||
assert rows.label("example/model-sub").split("example/model-sub", 1)[1].strip() == ""
|
||||
assert rows.label("gift/m:free").split("gift/m:free", 1)[1].strip() == "Free"
|
||||
assert rows.label("example/model-b").split("example/model-b", 1)[1].strip() == "Large"
|
||||
assert rows.has_top_tier
|
||||
# Dimming is unchanged: the full day stops the paid Large row only.
|
||||
assert not rows.usage.blocks("example/model-sub") and rows.usage.blocks("example/model-b")
|
||||
|
||||
|
||||
def test_the_in_chat_cli_picker_leaves_subscription_billed_rows_untagged(monkeypatch):
|
||||
from hermes_cli import model_picker_usage as mpu
|
||||
|
||||
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: None)
|
||||
row = {"slug": "nous",
|
||||
"plan_usage": {"kind": "usage_window", "subscription_model_ids": ["example/model-sub"]},
|
||||
"usage_levels": {"example/model-sub": "free", "gift/m:free": "free"}}
|
||||
rows, _line = mpu.annotate_window_plan_rows(row, ["example/model-sub", "gift/m:free"])
|
||||
assert [r.split() for r in rows] == [["example/model-sub"], ["gift/m:free", "Free"]]
|
||||
# An older payload without the field: unchanged (both tagged Free).
|
||||
old = {**row, "plan_usage": {"kind": "usage_window"}}
|
||||
assert [r.split() for r in mpu.annotate_window_plan_rows(old, ["example/model-sub"])[0]] == \
|
||||
[["example/model-sub", "Free"]]
|
||||
|
||||
|
||||
def test_messaging_pickers_leave_subscription_billed_rows_untagged(monkeypatch):
|
||||
from hermes_cli import model_picker_usage as mpu
|
||||
from hermes_cli import model_switch_providers as msp
|
||||
from agent import plan_limits as pl
|
||||
|
||||
monkeypatch.setattr(mpu, "nous_picker_usage", lambda **kw: _window_usage())
|
||||
monkeypatch.setattr(pl, "cached_pricing", lambda base_url="": dict(_SUBSCRIPTION_BILLED))
|
||||
providers = [{"slug": "nous", "api_url": "https://x/v1", "models": list(_SUBSCRIPTION_BILLED)}]
|
||||
msp._attach_window_model_notes(providers)
|
||||
assert providers[0]["model_labels"] == {"gift/m:free": "Free", "example/model-b": "Large"}
|
||||
assert mpu.picker_row_suffix(providers[0], "example/model-sub") == ""
|
||||
@@ -293,6 +293,8 @@ class PlanUsage(Result):
|
||||
gauges: list[PlanUsageGauge] = Field(default_factory=list)
|
||||
# Ids the gateway labels ``top_tier`` (Large).
|
||||
top_tier_model_ids: list[str] = Field(default_factory=list)
|
||||
# Ids billed to a subscription the account holds: ``free`` in ``usage_levels`` (never dimmed), no "Free" tag.
|
||||
subscription_model_ids: list[str] = Field(default_factory=list)
|
||||
featured_model_ids: list[str] = Field(default_factory=list)
|
||||
paused_reason: str | None = None
|
||||
# Past a full allowance, top-up credits pay (pay as you go): Keep going on credits on + a balance.
|
||||
|
||||
@@ -98,6 +98,20 @@ describe('blockingGauge / planBlocks (the one rule, agent/plan_limits.py twin)',
|
||||
expect(usageTag(dayFull, 'a/low')).toBeNull()
|
||||
})
|
||||
|
||||
it("subscription-billed rows: never dimmed, no Free tag", () => {
|
||||
const subscribed: Provider = {
|
||||
...dayFull,
|
||||
plan_usage: { ...dayFull.plan_usage!, subscription_model_ids: ['a/zero'] }
|
||||
}
|
||||
|
||||
expect(planBlocks(subscribed, 'a/zero')).toBe(false)
|
||||
expect(usageTag(subscribed, 'a/zero')).toBeNull()
|
||||
expect(usageTag(subscribed, 'a/free:free')).toBe('free')
|
||||
expect(usageTag(subscribed, 'a/large')).toBe('top_tier')
|
||||
// An older backend without the field: unchanged.
|
||||
expect(usageTag(dayFull, 'a/zero')).toBe('free')
|
||||
})
|
||||
|
||||
it('formats the reset compactly', () => {
|
||||
expect(formatResetsIn(NOW + 30_000, NOW)).toBe('soon')
|
||||
expect(formatResetsIn(NOW + 12 * 60_000, NOW)).toBe('12m')
|
||||
|
||||
Reference in new issue
Block a user