diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-08-09-external-providers.md | 188 |
1 files changed, 181 insertions, 7 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: |
