aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-09 16:38:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-09 16:38:44 +0200
commit46866c570c719ed02c8b43d688a2725bdcf5011c (patch)
treea018cf306f396de7098573ebd449e44b622775a7
parent5ffbe947736684df2b7a8dd1d647e16c080b0b1d (diff)
downloadllamachat-46866c570c719ed02c8b43d688a2725bdcf5011c.tar.gz
llamachat-46866c570c719ed02c8b43d688a2725bdcf5011c.zip
fix: reject non-finite provider prices, and read them as unpriced
A nan or inf in [providers.*] survived float() and reached the cost arithmetic, rendering $nan or, for -inf, a negative cost. Worse than a display wart: is_priced answered True, so the readout took the priced branch and showed a poisoned figure in the one state the ? is there to admit it cannot say. Guarded at both ends. providers._number mirrors models._get, which already covered the models.ini writer. is_priced states its own predicate completely rather than trusting its callers, since the dialog is a third writer and the file is documented for hand-editing. Records why float money stays, and that exact-equality assertions in the cost test hold for the chosen prices rather than in general. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-08-09-external-providers.md188
-rw-r--r--llamachat/models.py39
-rw-r--r--llamachat/providers.py10
-rwxr-xr-xtest_llamachat.py47
4 files changed, 273 insertions, 11 deletions
diff --git a/docs/superpowers/plans/2026-08-09-external-providers.md b/docs/superpowers/plans/2026-08-09-external-providers.md
index 88e050c..d65d6b8 100644
--- a/docs/superpowers/plans/2026-08-09-external-providers.md
+++ b/docs/superpowers/plans/2026-08-09-external-providers.md
@@ -186,6 +186,7 @@ of `llamachat/config.py`, then:
```python
"""Provider definitions, model-id namespacing and API key resolution."""
+import math
import os
import subprocess
from dataclasses import dataclass, field
@@ -226,9 +227,16 @@ def _number(raw, cast):
if raw is None or raw == "":
return None
try:
- return cast(raw)
+ value = cast(raw)
except (TypeError, ValueError):
return None
+ # float() accepts "nan" and "inf", which would survive every later check
+ # and surface as a "$nan" or "$-inf" cost. Caught here rather than at each
+ # cost call site, so the arithmetic downstream can assume a real number.
+ # Kept textually parallel to models._get, which guards the other writer.
+ if cast is float and not math.isfinite(value):
+ return None
+ return value
def parse(values: dict) -> dict[str, Provider]:
@@ -1539,12 +1547,62 @@ def test_metadata_and_cost():
assert bare.ctx_size is None
assert bare.price_in is None
+ # A local model resolves against the local provider rather than
+ # falling through to an unrelated one: an unprefixed id and an
+ # unconfigured prefix both split to local, and local configured
+ # nothing here, so nothing may be inherited.
+ assert models.resolve("gemma4", table, store).ctx_size is None
+ # A models.ini entry is keyed by the full id, so a local model's
+ # own metadata must still come back when no provider supplies any.
+ store.save("gemma4", models.ModelInfo(ctx_size=4096))
+ assert models.resolve("gemma4", table, store).ctx_size == 4096
+
+ # A config with cloud providers but no local one is legal, and
+ # split() still answers "local" for a bare id, so resolve() looks up
+ # a provider that is not in the table. The stored metadata must
+ # survive that, and nothing may be inherited from an unrelated
+ # provider: charging a local model together's prices would invent
+ # money that was never spent.
+ cloud_only = providers.parse(
+ {
+ "providers": {
+ "together": {
+ "base_url": "https://api.example.org",
+ "api_key": "env:X",
+ "ctx_size": 32768,
+ "price_in": 0.6,
+ }
+ }
+ }
+ )
+ orphan = models.resolve("gemma4", cloud_only, store)
+ assert orphan.ctx_size == 4096
+ assert orphan.price_in is None
+ assert models.is_billable("gemma4", cloud_only) is False
+ assert models.conversation_cost(
+ [{"model": "gemma4", "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000}], cloud_only, store
+ ) == 0.0
+
# Cost: prompt at the input rate, completion at the output rate.
+ #
+ # The exact == below is safe for these particular prices, not
+ # because the arithmetic is exact in general: n * p / n round-trips
+ # to p for 0.6 and 5.0, and 0.6 + 5.0 is exactly 5.6, the same way
+ # 0.1 + 0.2 is famously not 0.3. Adding a price here and asserting
+ # its exact total can fail in the last bits and look like a costing
+ # bug when it is only float representation. Use
+ # abs(cost - expected) < 1e-9 for any price you add.
rows = [
{"model": "together:other", "prompt_tokens": 1_000_000,
"completion_tokens": 1_000_000},
# A pre-migration row: no counts, no model. Contributes zero.
{"model": None, "prompt_tokens": None, "completion_tokens": None},
+ # Counts but no model, which is what a reply interrupted before
+ # it recorded its model leaves behind. There is no rate to apply,
+ # so it must contribute zero rather than borrow another row's.
+ {"model": None, "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000},
]
assert models.conversation_cost(rows, table, store) == 1.5
@@ -1563,14 +1621,82 @@ def test_metadata_and_cost():
"completion_tokens": 0}], table, store
) == 0.0
+ # A model priced on only one side still charges that side, rather
+ # than being all-or-nothing: together:specific has no price_out of
+ # its own but inherits one, so this uses a store-only provider.
+ assert models.conversation_cost(
+ [{"model": "free:half", "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000}], table, store
+ ) == 0.0
+ store.save("free:half", models.ModelInfo(price_in=2.0))
+ assert models.conversation_cost(
+ [{"model": "free:half", "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000}], table, store
+ ) == 2.0
+ # The other side alone, so neither rate is quietly gated on the
+ # other being known.
+ store.save("free:outonly", models.ModelInfo(price_out=3.0))
+ assert models.conversation_cost(
+ [{"model": "free:outonly", "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000}], table, store
+ ) == 3.0
+ assert models.is_priced("free:outonly", table, store) is True
+
+ # The projection prices input only: the reply's length is unknown
+ # until it arrives, so guessing it would overstate every turn.
+ assert models.projected_cost(
+ 1_000_000, "together:other", table, store
+ ) == 0.6
+ assert models.projected_cost(
+ 1_000_000, "free:anything", table, store
+ ) == 0.0
+
# Whether a model can be priced at all decides ? versus blank.
assert models.is_priced("together:other", table, store) is True
assert models.is_priced("free:anything", table, store) is False
+
+ # A non-finite price is one no cost can be computed from, so the
+ # answer is "not priced" rather than a True that sends the readout
+ # down the priced branch to show "$nan". Both of today's writers
+ # filter these out already; this states is_priced's own predicate
+ # completely, for Task 10's dialog and Task 15's hand-editing.
+ poisoned = providers.parse(
+ {"providers": {"p": {"base_url": "http://x.example.org",
+ "api_key": "env:X"}}}
+ )
+ poisoned["p"].price_in = float("nan")
+ poisoned["p"].price_out = float("-inf")
+ assert models.is_priced("p:x", poisoned, store) is False
# Local is free, never unpriced.
assert models.is_billable("gemma4", table) is False
assert models.is_billable("free:anything", table) is False # no api_key
assert models.is_billable("together:other", table) is True
+ # A local router behind an authenticating proxy is a legal config,
+ # and it is still free. Without this the assertion above passes
+ # only because the local provider happens to carry no api_key,
+ # which is what the is_local test actually exists to cover.
+ keyed_local = providers.parse(
+ {
+ "providers": {
+ "local": {
+ "base_url": "http://localhost:8181",
+ "api_key": "env:X",
+ "price_in": 9.0,
+ }
+ }
+ }
+ )
+ assert models.is_billable("gemma4", keyed_local) is False
+ # A modelless row must stay free even when the local provider it
+ # would otherwise resolve to carries prices, which is what makes
+ # the empty-id guard in message_cost() load-bearing rather than
+ # decorative.
+ assert models.conversation_cost(
+ [{"model": None, "prompt_tokens": 1_000_000,
+ "completion_tokens": 1_000_000}], keyed_local, store
+ ) == 0.0
+
# Formatting: cents matter, so three decimals below a dollar.
assert models.format_cost(0.0) == "$0.000"
assert models.format_cost(1.5) == "$1.50"
@@ -1602,6 +1728,12 @@ def resolve(model_id: str, table: dict, store: "ModelStore") -> ModelInfo:
Per field, not per source: a models.ini entry that sets only ctx_size
still inherits the provider's prices.
+
+ These three are not the whole merge. Task 12's model_info() in ui.py
+ layers presets.ini on top of what this returns, because models.py has
+ no business importing presets. The consequence of that split is that
+ "what does this model's ctx_size resolve to" has no single answer site,
+ so a reader here is looking at three of four layers.
"""
stored = store.get(model_id) or ModelInfo()
name, _ = providers_mod.split(model_id, table)
@@ -1625,6 +1757,12 @@ def is_billable(model_id: str, table: dict) -> bool:
Keyed on the provider having an api_key: that is what distinguishes a
free local model from a cloud one whose price was never entered.
+
+ Alone among these functions it takes no store, and that asymmetry is
+ load-bearing rather than an omission: whether a model costs money does
+ not depend on which prices we happen to have recorded. Pairing it with
+ is_priced is the point, one answers "is this billed", the other "can we
+ say how much". Do not add a store parameter to make it match.
"""
name, _ = providers_mod.split(model_id, table)
provider = table.get(name)
@@ -1632,13 +1770,34 @@ def is_billable(model_id: str, table: dict) -> bool:
def is_priced(model_id: str, table: dict, store: "ModelStore") -> bool:
- """Whether a cost can be computed for this model."""
+ """Whether a cost can be computed for this model.
+
+ A non-finite price is one no cost can be computed from, so it reads as
+ unpriced. Both of today's writers filter these out before they get here,
+ which makes this the predicate stated completely rather than a redundant
+ check: answering True for a nan would send the readout down the priced
+ branch to render "$nan" in the one state built to admit it cannot say.
+ """
info = resolve(model_id, table, store)
- return info.price_in is not None or info.price_out is not None
+ return any(
+ p is not None and math.isfinite(p) for p in (info.price_in, info.price_out)
+ )
def message_cost(row, table: dict, store: "ModelStore") -> float:
- """Cost of one stored assistant row, priced at the model that made it."""
+ """Cost of one stored assistant row, priced at the model that made it.
+
+ ponytail: binary floats for money, which is normally a mistake. It is
+ not one here because these are estimates against hand-typed prices,
+ never reconciled against a real bill, and Task 11's tooltip says
+ "Approximate". The magnitudes involved sit nowhere near double's
+ precision limit. The ceiling is that summing many rows accumulates
+ representation error, so a long conversation's total can differ in the
+ last cents from the same rows added another way. The upgrade path is
+ integer micro-dollars end to end, not Decimal: token counts are already
+ integers, so prices scaled to millionths keep the arithmetic exact and
+ the storage an INTEGER column.
+ """
model_id = row["model"] if row["model"] else ""
if not model_id:
return 0.0
@@ -1662,7 +1821,9 @@ def conversation_cost(rows, table: dict, store: "ModelStore") -> float:
return sum(message_cost(row, table, store) for row in rows)
-def projected_cost(tokens: int, model_id: str, table: dict, store) -> float:
+def projected_cost(
+ tokens: int, model_id: str, table: dict, store: "ModelStore"
+) -> float:
"""What sending `tokens` of input to this model would cost.
Only the input rate: the length of the reply is unknowable in advance.
@@ -2238,6 +2399,8 @@ Create `llamachat/modeldialog.py` with the GPL header copied from `config.py`, t
```python
"""Dialog for entering what presets.ini cannot answer about a model."""
+import math
+
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
@@ -2252,14 +2415,25 @@ from .models import ModelInfo
def _number(text: str, cast):
- """Field text to a number, treating blank and garbage alike as unknown."""
+ """Field text to a number, treating blank and garbage alike as unknown.
+
+ This is the third writer of a price, after providers._number and
+ models._get, and it needs their non-finite guard for the same reason:
+ "nan" and "inf" survive float() and would be stored, then rendered as a
+ "$nan" cost. is_priced() screens them downstream, so the readout stays
+ honest either way, but a stored nan is a value the dialog would show
+ back to the user on reopen.
+ """
text = (text or "").strip()
if not text:
return None
try:
- return cast(text)
+ value = cast(text)
except ValueError:
return None
+ if cast is float and not math.isfinite(value):
+ return None
+ return value
def to_info(ctx_text: str, vision: bool, in_text: str, out_text: str) -> ModelInfo:
diff --git a/llamachat/models.py b/llamachat/models.py
index a99b6a6..2b2da2b 100644
--- a/llamachat/models.py
+++ b/llamachat/models.py
@@ -219,6 +219,12 @@ def resolve(model_id: str, table: dict, store: "ModelStore") -> ModelInfo:
Per field, not per source: a models.ini entry that sets only ctx_size
still inherits the provider's prices.
+
+ These three are not the whole merge. Task 12's model_info() in ui.py
+ layers presets.ini on top of what this returns, because models.py has
+ no business importing presets. The consequence of that split is that
+ "what does this model's ctx_size resolve to" has no single answer site,
+ so a reader here is looking at three of four layers.
"""
stored = store.get(model_id) or ModelInfo()
name, _ = providers_mod.split(model_id, table)
@@ -242,6 +248,12 @@ def is_billable(model_id: str, table: dict) -> bool:
Keyed on the provider having an api_key: that is what distinguishes a
free local model from a cloud one whose price was never entered.
+
+ Alone among these functions it takes no store, and that asymmetry is
+ load-bearing rather than an omission: whether a model costs money does
+ not depend on which prices we happen to have recorded. Pairing it with
+ is_priced is the point, one answers "is this billed", the other "can we
+ say how much". Do not add a store parameter to make it match.
"""
name, _ = providers_mod.split(model_id, table)
provider = table.get(name)
@@ -249,13 +261,34 @@ def is_billable(model_id: str, table: dict) -> bool:
def is_priced(model_id: str, table: dict, store: "ModelStore") -> bool:
- """Whether a cost can be computed for this model."""
+ """Whether a cost can be computed for this model.
+
+ A non-finite price is one no cost can be computed from, so it reads as
+ unpriced. Both of today's writers filter these out before they get here,
+ which makes this the predicate stated completely rather than a redundant
+ check: answering True for a nan would send the readout down the priced
+ branch to render "$nan" in the one state built to admit it cannot say.
+ """
info = resolve(model_id, table, store)
- return info.price_in is not None or info.price_out is not None
+ return any(
+ p is not None and math.isfinite(p) for p in (info.price_in, info.price_out)
+ )
def message_cost(row, table: dict, store: "ModelStore") -> float:
- """Cost of one stored assistant row, priced at the model that made it."""
+ """Cost of one stored assistant row, priced at the model that made it.
+
+ ponytail: binary floats for money, which is normally a mistake. It is
+ not one here because these are estimates against hand-typed prices,
+ never reconciled against a real bill, and Task 11's tooltip says
+ "Approximate". The magnitudes involved sit nowhere near double's
+ precision limit. The ceiling is that summing many rows accumulates
+ representation error, so a long conversation's total can differ in the
+ last cents from the same rows added another way. The upgrade path is
+ integer micro-dollars end to end, not Decimal: token counts are already
+ integers, so prices scaled to millionths keep the arithmetic exact and
+ the storage an INTEGER column.
+ """
model_id = row["model"] if row["model"] else ""
if not model_id:
return 0.0
diff --git a/llamachat/providers.py b/llamachat/providers.py
index c31e82e..4c3e774 100644
--- a/llamachat/providers.py
+++ b/llamachat/providers.py
@@ -13,6 +13,7 @@
# GNU General Public License for more details.
"""Provider definitions, model-id namespacing and API key resolution."""
+import math
import os
import subprocess
import sys
@@ -54,9 +55,16 @@ def _number(raw, cast):
if raw is None or raw == "":
return None
try:
- return cast(raw)
+ value = cast(raw)
except (TypeError, ValueError):
return None
+ # float() accepts "nan" and "inf", which would survive every later check
+ # and surface as a "$nan" or "$-inf" cost. Caught here rather than at each
+ # cost call site, so the arithmetic downstream can assume a real number.
+ # Kept textually parallel to models._get, which guards the other writer.
+ if cast is float and not math.isfinite(value):
+ return None
+ return value
def _not_a_table(name: str, entry) -> str:
diff --git a/test_llamachat.py b/test_llamachat.py
index 695a303..5f96a80 100755
--- a/test_llamachat.py
+++ b/test_llamachat.py
@@ -960,6 +960,32 @@ def test_provider_parsing():
assert parsed["local"].ctx_size is None
assert parsed["local"].price_in is None
+ # nan and inf survive float() and would reach the cost arithmetic, where
+ # they render as "$nan" or "$-inf" in the *priced* branch: the readout
+ # inventing a figure in the one state built to admit it cannot say.
+ # Unknown is the honest answer, exactly as in models.ini.
+ nonfinite = providers.parse(
+ {
+ "providers": {
+ "p": {
+ "base_url": "http://x.example.org",
+ "price_in": float("nan"),
+ "price_out": float("inf"),
+ },
+ "n": {
+ "base_url": "http://y.example.org",
+ "price_in": float("-inf"),
+ "ctx_size": 8192,
+ },
+ }
+ }
+ )
+ assert nonfinite["p"].price_in is None
+ assert nonfinite["p"].price_out is None
+ # A bad price must not take the good ctx_size down with it.
+ assert nonfinite["n"].price_in is None
+ assert nonfinite["n"].ctx_size == 8192
+
# A [providers.local] that omits base_url inherits the bare one rather
# than shadowing the local provider out of existence.
partial = providers.parse(
@@ -1579,6 +1605,14 @@ def test_metadata_and_cost():
) == 0.0
# Cost: prompt at the input rate, completion at the output rate.
+ #
+ # The exact == below is safe for these particular prices, not
+ # because the arithmetic is exact in general: n * p / n round-trips
+ # to p for 0.6 and 5.0, and 0.6 + 5.0 is exactly 5.6, the same way
+ # 0.1 + 0.2 is famously not 0.3. Adding a price here and asserting
+ # its exact total can fail in the last bits and look like a costing
+ # bug when it is only float representation. Use
+ # abs(cost - expected) < 1e-9 for any price you add.
rows = [
{"model": "together:other", "prompt_tokens": 1_000_000,
"completion_tokens": 1_000_000},
@@ -1640,6 +1674,19 @@ def test_metadata_and_cost():
# Whether a model can be priced at all decides ? versus blank.
assert models.is_priced("together:other", table, store) is True
assert models.is_priced("free:anything", table, store) is False
+
+ # A non-finite price is one no cost can be computed from, so the
+ # answer is "not priced" rather than a True that sends the readout
+ # down the priced branch to show "$nan". Both of today's writers
+ # filter these out already; this states is_priced's own predicate
+ # completely, for Task 10's dialog and Task 15's hand-editing.
+ poisoned = providers.parse(
+ {"providers": {"p": {"base_url": "http://x.example.org",
+ "api_key": "env:X"}}}
+ )
+ poisoned["p"].price_in = float("nan")
+ poisoned["p"].price_out = float("-inf")
+ assert models.is_priced("p:x", poisoned, store) is False
# Local is free, never unpriced.
assert models.is_billable("gemma4", table) is False
assert models.is_billable("free:anything", table) is False # no api_key