Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ and its resources. The source layout stays under `app/`.
(the reference `agent`; JiuwenSwarm's `agent` and mini-swe-agent's `run`, the wrappers that start them) and `catalogue/capabilities/<key>/` (the keyword-memory `capability`); `workspace` (and
`workspace_cli`, its shell command),
`cli` and `__main__` (the unified `nullius` and `python -m nullius` commands),
`paths` (user config, data and cache locations), `migrate` (explicit copying of legacy checkout storage), `settings`, `hosts`, `execution`, `progress` (what a run is doing, one line per stage), `prerequisites` (what a recipe needs of each machine it runs on, asked before Run and again as it starts), `onboarding` (the Getting started checklist: what this machine
`paths` (user config, data and cache locations), `prices` (the user's model price list: fetched on request, never by a run; what a cost is estimated from), `migrate` (explicit copying of legacy checkout storage), `settings`, `hosts`, `execution`, `progress` (what a run is doing, one line per stage), `prerequisites` (what a recipe needs of each machine it runs on, asked before Run and again as it starts), `onboarding` (the Getting started checklist: what this machine
says is done, and where each step is done), `gitrefs` (a repository's branches and tags,
by `git ls-remote`), `secrets` (a benchmark's licences, kept per benchmark outside the repository), `sandbox` (Settings' docker: its Docker Hub login and the images runs keep) and `redact` at the top.
- `app/templates/`, `app/static/` — the interface, grouped by the same capability
Expand Down Expand Up @@ -110,8 +110,8 @@ remain available; the executable is `nullius` and storage paths keep that name.
Harbor a recipe runs is its pin's, run through `uvx` (or the target's own, for
a recipe without a pin), and the viewer never imports it.

`nullius paths` lists persistent locations. On Linux, settings and benchmark
licences live under `~/.config/nullius/`, user catalogue entries, workspaces,
`nullius paths` lists persistent locations. On Linux, settings, the model price
list and benchmark licences live under `~/.config/nullius/`, user catalogue entries, workspaces,
runs and standard installs under `~/.local/share/nullius/`, and caches under
`~/.cache/nullius/`. XDG variables move those roots; `NULLIUS_HOME` selects a
separate profile with `config/`, `data/` and `cache/`. Existing storage overrides
Expand Down
1 change: 1 addition & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Start-up options, not on the Settings page:
# NULLIUS_HOME: a separate profile with config/, data/ and cache/ directories
# NULLIUS_CATALOGUE: the user catalogue directory (default catalogue/ in the user data directory)
# NULLIUS_PRICES: the model price list file (default prices.json in the user configuration directory); set for a run on a host
# APP_HOST: the address the server binds (default 127.0.0.1, or --host)
# APP_PORT: the port the server listens on (default 5057, or --port)
# JOB_ROOTS: job roots to open at start (or the command-line argument)
Expand Down
2 changes: 2 additions & 0 deletions app/nullius/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"standard": "inspect and run standard definitions",
"workspace": "manage workspaces",
"paths": "show persistent storage locations",
"prices": "show and update model prices",
"migrate": "copy legacy storage into the user profile",
}
MODULES = {
Expand All @@ -29,6 +30,7 @@
"standard": "nullius.standard.cli",
"workspace": "nullius.workspace_cli",
"paths": "nullius.paths",
"prices": "nullius.prices",
"migrate": "nullius.migrate",
}

Expand Down
12 changes: 11 additions & 1 deletion app/nullius/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
from datetime import datetime, timezone
from typing import Callable, Dict, List, Optional

from nullius import hosts, paths, progress, redact, secrets, settings
from nullius import hosts, paths, prices as price_list, progress, redact, secrets, settings
from nullius.harbor import source as harbor_source
from nullius.standard import payload as standard_payload

Expand Down Expand Up @@ -1450,13 +1450,23 @@ def _launch(self, part: dict) -> Optional[bool]:
if rc != 0:
self._fail("run", f"the script did not land on {where}: " + out.strip()[-400:])
return False
prices_on_target = ""
if self.plan.runner == STANDARD and self.on_host:
# the price list is this machine's user's, so the run takes it along (nullius.prices) - an empty one
# too: a host's own list, if it has one, never prices a run started here
prices_on_target = run_dir + "/prices.json"
rc, out = self._sh(f"cat > {self._q(prices_on_target)}", 60, stdin=json.dumps(price_list.load()) + "\n")
if rc != 0:
self._fail("run", f"the price list did not land on {where}: " + out.strip()[-400:])
return False
exports = " ".join(f"{k}={self._q(v) if v.startswith('~') else shlex.quote(v)}" for k, v in
[("NULLIUS_HARBOR", self.harbor), ("NULLIUS_TASKS", self.tasks_on_target), ("NULLIUS_JOBS", run_dir + "/jobs"),
("NULLIUS_PROGRESS", "1")]
+ ([("NULLIUS_STANDARD", self.app_on_target or REPO), ("PYTHONDONTWRITEBYTECODE", "1")]
if self.plan.runner == STANDARD else [])
+ ([("NULLIUS_CATALOGUE", self.catalogue_on_target)]
if self.plan.runner == STANDARD and self.catalogue_on_target else [])
+ ([("NULLIUS_PRICES", prices_on_target)] if prices_on_target else [])
+ ([("NULLIUS_PYTHON", sys.executable)] if self.plan.runner == STANDARD and not self.on_host else [])
+ ([("NULLIUS_LICENCE_DIR", self._licence_dir())] if self._licence_dir() else [])
+ ([("API_MODEL_NAME", self.plan.model)] if self.plan.model else []))
Expand Down
7 changes: 6 additions & 1 deletion app/nullius/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ def settings_file(env=None) -> str:
return os.path.join(config_dir(env), "settings.json")


def prices_file(env=None) -> str:
env = os.environ if env is None else env
return env.get("NULLIUS_PRICES") or os.path.join(config_dir(env), "prices.json")


def catalogue_dir(env=None) -> str:
env = os.environ if env is None else env
return env.get("NULLIUS_CATALOGUE") or os.path.join(data_dir(env), "catalogue")
Expand All @@ -70,7 +75,7 @@ def main(argv=None) -> int:
parser = argparse.ArgumentParser(description="Show Nullius's persistent user directories (no secret values).")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
values = {"settings": settings_file(), "secrets": secrets_dir(), "catalogue": catalogue_dir(),
values = {"settings": settings_file(), "secrets": secrets_dir(), "prices": prices_file(), "catalogue": catalogue_dir(),
"workspaces": workspaces_dir(), "runs": runs_dir(), "cache": cache_dir()}
if args.json:
print(json.dumps(values, indent=2))
Expand Down
279 changes: 279 additions & 0 deletions app/nullius/prices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
"""Model prices, the user's own: what a call's cost is estimated from when
the provider's reply does not carry one.

Nullius ships no price. The list lives in one file of the user's
configuration, ``prices.json`` (``NULLIUS_PRICES`` names another: the file a
host is sent), in two parts:

``models`` a public list, fetched on request (``update``) and kept whole
with where it came from and when: LiteLLM's
``model_prices_and_context_window.json``, keyed by the model
names the providers themselves use
``own`` the user's rows, which win over the list: a price it lacks, a
negotiated one, a model served under a name of one's own

A row is USD per million tokens: ``input`` and ``output``, optionally
``cached`` (input a cache served) and ``cache_write``, and ``max_output``,
the model's output limit in tokens. A model is priced by exactly its name or
not at all: a name that only starts like another's is another model.

Nothing here runs during a run: a run reads the file, never the network.

nullius prices # where the list came from, how many models, the user's own rows
nullius prices list MODEL ... # those models' rows
nullius prices update [--from URL-or-file]
nullius prices set MODEL INPUT OUTPUT [--cached X] [--cache-write X] [--max-output N]
nullius prices remove MODEL
"""
from __future__ import annotations

import argparse
import json
import os
import time
import urllib.request
from typing import Callable, Dict, Iterable, Optional, Tuple

from nullius import paths

SOURCE = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
RATES = ("input", "output", "cached", "cache_write") # USD per million tokens
_LISTED = {"input": "input_cost_per_token", "output": "output_cost_per_token",
"cached": "cache_read_input_token_cost", "cache_write": "cache_creation_input_token_cost"}
_MOST = 64 * 1024 * 1024 # a list larger than this is not a price list


def path(env=None) -> str:
return paths.prices_file(env)


def load(env=None) -> dict:
"""The file as ``{from, on, models, own}``; missing or unreadable, empty:
no price is better than a run that cannot start for its price list."""
doc = {"from": "", "on": "", "models": {}, "own": {}}
try:
with open(path(env), encoding="utf-8") as f:
got = json.load(f)
except (OSError, ValueError):
return doc
if isinstance(got, dict):
for k in ("from", "on"):
if isinstance(got.get(k), str):
doc[k] = got[k]
for k in ("models", "own"):
if isinstance(got.get(k), dict):
doc[k] = {n: r for n, r in got[k].items() if _usable(r)}
return doc


def _usable(r) -> bool:
return isinstance(r, dict) and all(_number(r.get(k)) for k in ("input", "output"))


def _number(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool) and v >= 0


def save(doc: dict, env=None) -> None:
"""Written whole beside the file, then renamed: a reader never finds half a list."""
out = path(env)
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
part = f"{out}.{os.getpid()}.part"
with open(part, "w", encoding="utf-8") as f:
json.dump(doc, f, indent=1, sort_keys=True)
f.write("\n")
os.replace(part, out)


def table(env=None) -> Dict[str, dict]:
"""Every priced model: the list, then the user's own rows over it."""
doc = load(env)
return {**doc["models"], **doc["own"]}


def given(names: Iterable[Optional[str]], prices: Optional[Dict[str, dict]]) -> Dict[str, dict]:
"""The rows of ``prices`` for exactly ``names``: what a relay started as
a process of its own is handed, which cannot take the whole list."""
prices = prices or {}
return {n: prices[n] for n in dict.fromkeys(n for n in names if n) if n in prices}


def row(input, output, cached=None, cache_write=None, max_output=None) -> dict:
"""One row, checked: rates are numbers not below zero, the limit a whole number above it."""
out = {}
for name, v in (("input", input), ("output", output), ("cached", cached), ("cache_write", cache_write)):
if v is None:
continue
try:
v = float(v)
except (TypeError, ValueError):
raise ValueError(f"{name}: a price in USD per million tokens, not {v!r}")
if not v >= 0 or v == float("inf"):
raise ValueError(f"{name}: a price in USD per million tokens, not {v!r}")
out[name] = v
if max_output is not None:
try:
n = int(max_output)
except (TypeError, ValueError):
n = 0
if n <= 0:
raise ValueError(f"max_output: the model's output limit in tokens, not {max_output!r}")
out["max_output"] = n
return out


def parse(text: str) -> Tuple[str, dict]:
"""``MODEL=INPUT,OUTPUT[,CACHED]``, a run's ``--price``."""
name, eq, rest = str(text).partition("=")
bits = [b.strip() for b in rest.split(",")] if eq else []
if not name.strip() or not 2 <= len(bits) <= 3 or not all(bits):
raise ValueError(f"--price {text!r}: MODEL=INPUT,OUTPUT[,CACHED], in USD per million tokens")
try:
return name.strip(), row(*bits)
except ValueError as e:
raise ValueError(f"--price {text!r}: {e}")


def convert(listing) -> Dict[str, dict]:
"""LiteLLM's list as rows: its costs are per token, a row's per million.
An entry with no input price is not a priced model (the list's own
sample, an image model priced per picture) and is left out."""
if not isinstance(listing, dict):
raise ValueError("not a price list: a JSON object of models was expected")
out = {}
for name, e in listing.items():
if not isinstance(e, dict) or not _number(e.get(_LISTED["input"])):
continue
r = {"input": round(e[_LISTED["input"]] * 1e6, 6),
"output": round(e[_LISTED["output"]] * 1e6, 6) if _number(e.get(_LISTED["output"])) else 0.0}
for k in ("cached", "cache_write"):
if _number(e.get(_LISTED[k])):
r[k] = round(e[_LISTED[k]] * 1e6, 6)
limit = e.get("max_output_tokens")
if isinstance(limit, int) and not isinstance(limit, bool) and limit > 0:
r["max_output"] = limit
out[str(name)] = r
if not out:
raise ValueError("not a price list: no model in it has a price")
return out


def _fetch(source: str) -> bytes:
if source.startswith("https://"):
req = urllib.request.Request(source, headers={"User-Agent": "nullius"}) # nothing of the user or the machine
with urllib.request.urlopen(req, timeout=60) as r:
return r.read(_MOST + 1)
if "://" in source:
raise ValueError("only an https address or a file is read")
with open(source, "rb") as f:
return f.read(_MOST + 1)


def update(source: Optional[str] = None, env=None, fetch: Optional[Callable[[str], bytes]] = None,
today: Optional[str] = None) -> dict:
"""The public list fetched and kept in place of the last one; the user's
own rows are not touched. Nothing is written when the fetch or the list
is bad: the list held stays. Returns ``{n, from, on}``."""
source = source or SOURCE
try:
raw = (fetch or _fetch)(source)
except (OSError, ValueError) as e:
why = e.strerror if isinstance(e, OSError) and e.filename else e # a file's error names the file again
raise ValueError(f"could not fetch the price list from {source}: {why}")
if len(raw) > _MOST:
raise ValueError(f"{source}: too large to be a price list")
try:
models = convert(json.loads(raw))
except ValueError as e:
raise ValueError(f"{source}: {e}")
doc = load(env)
doc.update({"from": source, "on": today or time.strftime("%Y-%m-%d"), "models": models})
save(doc, env)
return {"n": len(models), "from": doc["from"], "on": doc["on"]}


def set_own(name: str, r: dict, env=None) -> None:
name = str(name).strip()
if not name or not _usable(r):
raise ValueError("a model's name, and its input and output prices")
doc = load(env)
doc["own"][name] = r
save(doc, env)


def remove_own(name: str, env=None) -> bool:
doc = load(env)
if doc["own"].pop(str(name).strip(), None) is None:
return False
save(doc, env)
return True


def state(env=None) -> dict:
"""What Settings shows: how many models the list prices, where it came
from and when, and the user's own rows."""
doc = load(env)
return {"n": len(doc["models"]), "from": doc["from"], "on": doc["on"], "own": doc["own"]}


def said(name: str, r: dict) -> str:
bits = [f"input {r['input']:g}", f"output {r['output']:g}"]
bits += [f"{label} {r[k]:g}" for k, label in (("cached", "cached"), ("cache_write", "cache write")) if k in r]
if "max_output" in r:
bits.append(f"output limit {r['max_output']}")
return f"{name}: " + ", ".join(bits)


def main(argv=None) -> int:
ap = argparse.ArgumentParser(prog="nullius prices",
description="The user's model prices, in USD per million tokens: what a run's cost is estimated from.")
sub = ap.add_subparsers(dest="cmd")
ls = sub.add_parser("list", help="the list's source and size, the user's own rows, and the rows of the models named")
ls.add_argument("models", nargs="*")
ls.add_argument("--json", action="store_true")
up = sub.add_parser("update", help="fetch the public list and keep it; the user's own rows stay")
up.add_argument("--from", dest="source", metavar="URL-or-file", help=f"another list in the same format (default: {SOURCE})")
st = sub.add_parser("set", help="a price of the user's own, which wins over the list")
st.add_argument("model")
st.add_argument("input")
st.add_argument("output")
st.add_argument("--cached")
st.add_argument("--cache-write")
st.add_argument("--max-output")
rm = sub.add_parser("remove", help="remove one of the user's own prices")
rm.add_argument("model")
a = ap.parse_args(argv)
try:
if a.cmd == "update":
got = update(a.source)
print(f"{got['n']} models priced, from {got['from']}")
return 0
if a.cmd == "set":
r = row(a.input, a.output, a.cached, a.cache_write, a.max_output)
set_own(a.model, r)
print(said(a.model.strip(), r))
return 0
if a.cmd == "remove":
if not remove_own(a.model):
ap.exit(1, f"{a.model}: not one of your own prices\n")
return 0
except ValueError as e:
ap.exit(1, f"{e}\n")
doc, names = load(), list(getattr(a, "models", None) or [])
both = {**doc["models"], **doc["own"]}
if getattr(a, "json", False):
print(json.dumps({"file": path(), "from": doc["from"], "on": doc["on"], "n": len(doc["models"]), "own": doc["own"],
"models": {n: both.get(n) for n in names}}, indent=2))
return 0
print(f"file: {path()}")
print(f"list: {len(doc['models'])} models, from {doc['from']} on {doc['on']}" if doc["models"]
else "list: none yet - nullius prices update")
for n, r in sorted(doc["own"].items()):
print("own " + said(n, r))
for n in names:
print(said(n, both[n]) if n in both else f"{n}: no price")
return 0


if __name__ == "__main__":
raise SystemExit(main())
1 change: 1 addition & 0 deletions app/nullius/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def validate(self, value: str) -> Optional[str]:
STARTUP: Dict[str, str] = {
"NULLIUS_HOME": "a separate profile with config/, data/ and cache/ directories",
"NULLIUS_CATALOGUE": "the user catalogue directory (default catalogue/ in the user data directory)",
"NULLIUS_PRICES": "the model price list file (default prices.json in the user configuration directory); set for a run on a host",
"APP_HOST": "the address the server binds (default 127.0.0.1, or --host)",
"APP_PORT": "the port the server listens on (default 5057, or --port)",
"JOB_ROOTS": "job roots to open at start (or the command-line argument)",
Expand Down
Loading
Loading