lisa-cli 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {lisa_cli-0.2.0/src/lisa_cli.egg-info → lisa_cli-0.3.0}/PKG-INFO +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/pyproject.toml +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/main.py +154 -78
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/transport.py +202 -39
- {lisa_cli-0.2.0 → lisa_cli-0.3.0/src/lisa_cli.egg-info}/PKG-INFO +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/SOURCES.txt +1 -1
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_transport.py +157 -16
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_transport_stage.py +195 -0
- lisa_cli-0.3.0/tests/test_update_command.py +249 -0
- lisa_cli-0.2.0/tests/test_run_command.py +0 -132
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/LICENSE +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/setup.cfg +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/__init__.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/__init__.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/entry.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/expand.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/hosts/cli/render.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/dependency_links.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/entry_points.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/requires.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/src/lisa_cli.egg-info/top_level.txt +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_approval_panel.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_group_resolution.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_last_command.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_models_command.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_pack_update.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_profile_option.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_command.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_decisions.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_entry.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_prompt_expand.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_render_catalogs.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_render_models.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_restart.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_runtime_teardown.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_streaming.py +0 -0
- {lisa_cli-0.2.0 → lisa_cli-0.3.0}/tests/test_verify_command.py +0 -0
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
This is the terminal-transport entry point for the Lisa framework. It
|
|
4
4
|
wraps the transport-neutral :mod:`host` runtime with Typer commands
|
|
5
5
|
and Rich rendering. Every command is a one-shot process: turns run via
|
|
6
|
-
``lisa prompt`` and lifecycle/inspection commands (verify,
|
|
7
|
-
pack, mcp, last, intake) run once and exit.
|
|
6
|
+
``lisa prompt`` and lifecycle/inspection commands (verify, update, models,
|
|
7
|
+
policy, pack, mcp, last, intake) run once and exit.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -14,6 +14,8 @@ import logging
|
|
|
14
14
|
import os
|
|
15
15
|
import platform
|
|
16
16
|
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
17
19
|
import sys
|
|
18
20
|
from collections.abc import Callable
|
|
19
21
|
from pathlib import Path
|
|
@@ -415,7 +417,7 @@ def _get_version() -> str:
|
|
|
415
417
|
|
|
416
418
|
return _pkg_version("lisa-cli")
|
|
417
419
|
except Exception: # pylint: disable=broad-except
|
|
418
|
-
return "0.
|
|
420
|
+
return "0.3.0"
|
|
419
421
|
|
|
420
422
|
|
|
421
423
|
_VERSION_OPTION = typer.Option(
|
|
@@ -1233,7 +1235,7 @@ def _models_command(
|
|
|
1233
1235
|
await runtime.start()
|
|
1234
1236
|
return await handle_models_command(runtime, stripped, stripped)
|
|
1235
1237
|
finally:
|
|
1236
|
-
await runtime
|
|
1238
|
+
await _aclose_runtime_bounded(runtime)
|
|
1237
1239
|
|
|
1238
1240
|
response_text = asyncio.run(_run_models_command())
|
|
1239
1241
|
console.print(Panel(Markdown(response_text), title="Models", border_style="cyan"))
|
|
@@ -1900,6 +1902,142 @@ async def _run_pack_update_all(runtime: HostRuntime) -> tuple[bool, str]:
|
|
|
1900
1902
|
return await run_pack_update_all(runtime)
|
|
1901
1903
|
|
|
1902
1904
|
|
|
1905
|
+
# ---------------------------------------------------------------------------
|
|
1906
|
+
# update command
|
|
1907
|
+
# ---------------------------------------------------------------------------
|
|
1908
|
+
|
|
1909
|
+
_UPDATE_TOOL_NAME = "lisa-cli"
|
|
1910
|
+
|
|
1911
|
+
#: uv cannot replace a running console-script trampoline on Windows; the
|
|
1912
|
+
#: command reports the argv for the operator to run after exiting instead.
|
|
1913
|
+
_UPDATE_IN_PLACE_BLOCKED = platform.system() == "Windows"
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
@app.command(
|
|
1917
|
+
"update",
|
|
1918
|
+
help="Upgrade the installed lisa CLI in place (uv tool upgrade lisa-cli).",
|
|
1919
|
+
)
|
|
1920
|
+
def _update_command(
|
|
1921
|
+
force: bool = typer.Option(
|
|
1922
|
+
False,
|
|
1923
|
+
"--force",
|
|
1924
|
+
help=(
|
|
1925
|
+
"Reinstall even when the recorded version is unchanged "
|
|
1926
|
+
"(picks up new code from a local or git checkout without a version bump)."
|
|
1927
|
+
),
|
|
1928
|
+
),
|
|
1929
|
+
dry_run: bool = typer.Option(
|
|
1930
|
+
False,
|
|
1931
|
+
"--dry-run",
|
|
1932
|
+
help="Print the uv command instead of running it.",
|
|
1933
|
+
),
|
|
1934
|
+
) -> None:
|
|
1935
|
+
"""Upgrade the tool install that provides this ``lisa`` executable.
|
|
1936
|
+
|
|
1937
|
+
``uv tool upgrade`` is the only correct update path: it replays the
|
|
1938
|
+
install receipt (``<tool-dir>/lisa-cli/uv-receipt.toml``), which is where
|
|
1939
|
+
the installer records the ``--with`` framework requirements, the
|
|
1940
|
+
``[prompt]`` extra, and ``no-sources``. ``uv tool install --upgrade
|
|
1941
|
+
lisa-cli`` would rewrite that receipt from a bare index spec and drop
|
|
1942
|
+
all of it, so this command never does that.
|
|
1943
|
+
"""
|
|
1944
|
+
from runtime.paths import default_packs_venv, is_tool_install
|
|
1945
|
+
|
|
1946
|
+
if not is_tool_install():
|
|
1947
|
+
console.print(
|
|
1948
|
+
"[yellow]lisa is running from a repo checkout, so there is no uv tool to "
|
|
1949
|
+
"upgrade. Update with:[/yellow] git pull && uv sync"
|
|
1950
|
+
)
|
|
1951
|
+
raise typer.Exit(code=2)
|
|
1952
|
+
|
|
1953
|
+
tool_dir = _installed_tool_dir()
|
|
1954
|
+
if tool_dir is None:
|
|
1955
|
+
console.print(
|
|
1956
|
+
"[yellow]This interpreter is not a uv tool install (no uv-receipt.toml in "
|
|
1957
|
+
f"{sys.prefix}), so there is nothing for `uv tool upgrade` to update.[/yellow]"
|
|
1958
|
+
)
|
|
1959
|
+
raise typer.Exit(code=2)
|
|
1960
|
+
|
|
1961
|
+
uv_bin = shutil.which("uv")
|
|
1962
|
+
# Point uv at the tool dir this process is actually running from. Without
|
|
1963
|
+
# it uv consults only UV_TOOL_DIR/default, so an install made with a custom
|
|
1964
|
+
# tool dir would miss the real one and advise `uv tool install lisa-cli` —
|
|
1965
|
+
# the receipt-clobbering path this command exists to avoid.
|
|
1966
|
+
env = {**os.environ, "UV_TOOL_DIR": str(tool_dir.parent)}
|
|
1967
|
+
argv = [
|
|
1968
|
+
uv_bin or "uv",
|
|
1969
|
+
"tool",
|
|
1970
|
+
"upgrade",
|
|
1971
|
+
*(["--reinstall"] if force else []),
|
|
1972
|
+
_UPDATE_TOOL_NAME,
|
|
1973
|
+
]
|
|
1974
|
+
printable = shlex.join(argv)
|
|
1975
|
+
|
|
1976
|
+
# On Windows the console script is a .exe trampoline inside the tool
|
|
1977
|
+
# venv, which cannot be replaced while this process still has it open.
|
|
1978
|
+
blocked = uv_bin is None or (_UPDATE_IN_PLACE_BLOCKED and not dry_run)
|
|
1979
|
+
if dry_run or blocked:
|
|
1980
|
+
if dry_run:
|
|
1981
|
+
console.print(f"Run: [bold]{printable}[/bold]")
|
|
1982
|
+
else:
|
|
1983
|
+
reason = (
|
|
1984
|
+
"uv is not on PATH; run it yourself"
|
|
1985
|
+
if uv_bin is None
|
|
1986
|
+
else "Windows locks the running lisa.exe"
|
|
1987
|
+
)
|
|
1988
|
+
console.print(f"[yellow]{reason}.[/yellow] Run: [bold]{printable}[/bold]")
|
|
1989
|
+
raise typer.Exit(code=0 if dry_run else 1)
|
|
1990
|
+
|
|
1991
|
+
console.print(f"Running [bold]{printable}[/bold]")
|
|
1992
|
+
completed = subprocess.run(argv, check=False, env=env) # inherits stdio so uv streams progress
|
|
1993
|
+
if completed.returncode != 0:
|
|
1994
|
+
console.print(f"[red]uv tool upgrade failed (exit {completed.returncode}).[/red]")
|
|
1995
|
+
raise typer.Exit(code=completed.returncode)
|
|
1996
|
+
|
|
1997
|
+
console.print("Updated. Restart lisa to use the new version.")
|
|
1998
|
+
_warn_stale_packs_venv(default_packs_venv())
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def _installed_tool_dir() -> Path | None:
|
|
2002
|
+
"""Return the uv tool directory this process is running from.
|
|
2003
|
+
|
|
2004
|
+
A ``uv tool`` venv carries the receipt at its root, so ``sys.prefix``
|
|
2005
|
+
identifies the install unambiguously — including installs made with a
|
|
2006
|
+
custom ``UV_TOOL_DIR``, which uv would otherwise not find. The receipt's
|
|
2007
|
+
parent is the value ``UV_TOOL_DIR`` expects (it is the directory holding
|
|
2008
|
+
one subdirectory per tool).
|
|
2009
|
+
|
|
2010
|
+
This is a positive check: a plain virtualenv or pipx install has no
|
|
2011
|
+
receipt and returns ``None``.
|
|
2012
|
+
"""
|
|
2013
|
+
prefix = Path(sys.prefix)
|
|
2014
|
+
if not (prefix / "uv-receipt.toml").is_file():
|
|
2015
|
+
return None
|
|
2016
|
+
return prefix
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def _warn_stale_packs_venv(packs_venv: Path) -> None:
|
|
2020
|
+
"""Point the operator at the one follow-up packs may need.
|
|
2021
|
+
|
|
2022
|
+
The packs venv is a second install with its own framework copies. Packs
|
|
2023
|
+
import the CLI's framework packages (the tool venv's site-packages
|
|
2024
|
+
precedes the packs venv on ``sys.path``), so an upgrade does not break
|
|
2025
|
+
them — but a pack whose *dependencies* moved can still need a re-resolve,
|
|
2026
|
+
and ``uv pip install`` never upgrades an already-satisfied requirement.
|
|
2027
|
+
``lisa pack update --all`` re-installs each pack from its recorded source,
|
|
2028
|
+
which re-resolves its dependency set. Surfacing the command is cheaper
|
|
2029
|
+
than silently re-seeding every pack after an upgrade.
|
|
2030
|
+
"""
|
|
2031
|
+
if not packs_venv.is_dir():
|
|
2032
|
+
return
|
|
2033
|
+
console.print(
|
|
2034
|
+
f"[yellow]Packs live in a separate venv ({packs_venv}). Packs keep working "
|
|
2035
|
+
"against the upgraded framework, but their own dependencies are only "
|
|
2036
|
+
"re-resolved by `lisa pack update --all` — run it if a pack misbehaves "
|
|
2037
|
+
"after an update.[/yellow]"
|
|
2038
|
+
)
|
|
2039
|
+
|
|
2040
|
+
|
|
1903
2041
|
# ---------------------------------------------------------------------------
|
|
1904
2042
|
# prompt command
|
|
1905
2043
|
# ---------------------------------------------------------------------------
|
|
@@ -2064,7 +2202,7 @@ async def _run_host_command_turn(runtime: HostRuntime, command: str) -> str:
|
|
|
2064
2202
|
await runtime.start()
|
|
2065
2203
|
result = await handle_message(runtime, command, NullTransport())
|
|
2066
2204
|
finally:
|
|
2067
|
-
await runtime
|
|
2205
|
+
await _aclose_runtime_bounded(runtime)
|
|
2068
2206
|
return getattr(result, "text", "") or str(result)
|
|
2069
2207
|
|
|
2070
2208
|
|
|
@@ -2128,7 +2266,7 @@ async def _decide_approval_turn(
|
|
|
2128
2266
|
transport=NullTransport(),
|
|
2129
2267
|
)
|
|
2130
2268
|
finally:
|
|
2131
|
-
await runtime
|
|
2269
|
+
await _aclose_runtime_bounded(runtime)
|
|
2132
2270
|
return getattr(result, "text", "") or str(result)
|
|
2133
2271
|
|
|
2134
2272
|
|
|
@@ -2392,76 +2530,6 @@ def _deny_command( # Match shared runtime options used by one-shot commands.
|
|
|
2392
2530
|
)
|
|
2393
2531
|
|
|
2394
2532
|
|
|
2395
|
-
@app.command(
|
|
2396
|
-
"run",
|
|
2397
|
-
hidden=True,
|
|
2398
|
-
help=(
|
|
2399
|
-
"[DEPRECATED] Run one slash command (host admin or pack-defined). "
|
|
2400
|
-
"Every host command now has a real subcommand (pack-registered ones "
|
|
2401
|
-
"like `stalls`/`retry` are generated from the pack registry); this "
|
|
2402
|
-
"escape hatch remains one release for compatibility. The leading '/' "
|
|
2403
|
-
"is optional. Quote multi-word arguments as one string; with multiple "
|
|
2404
|
-
"tokens they are re-quoted verbatim so the runtime sees the exact "
|
|
2405
|
-
"slash-command string."
|
|
2406
|
-
),
|
|
2407
|
-
)
|
|
2408
|
-
def _run_command_command( # Match shared runtime options used by one-shot commands.
|
|
2409
|
-
command: Annotated[
|
|
2410
|
-
list[str] | None,
|
|
2411
|
-
typer.Argument(help="Slash command and its arguments."),
|
|
2412
|
-
] = None,
|
|
2413
|
-
log_level: LogLevelOption = "",
|
|
2414
|
-
log: LogOption = "",
|
|
2415
|
-
no_log: NoLogOption = False,
|
|
2416
|
-
data_dir: DataDirOption = "",
|
|
2417
|
-
profile: ProfileOption = "",
|
|
2418
|
-
overlays_dir: OverlaysDirOption = "",
|
|
2419
|
-
thread_dir: ThreadDirOption = "",
|
|
2420
|
-
no_thread: NoThreadOption = False,
|
|
2421
|
-
session_context: SessionContextOption = "",
|
|
2422
|
-
timezone: TimezoneOption = "",
|
|
2423
|
-
state_snapshot: StateSnapshotOption = "",
|
|
2424
|
-
streaming: StreamingOption = "",
|
|
2425
|
-
host_log: HostLogOption = "",
|
|
2426
|
-
) -> None:
|
|
2427
|
-
"""Dispatch one slash command to the runtime exactly as a persistent
|
|
2428
|
-
host would, via ``handle_message`` and ``_run_host_command_turn``.
|
|
2429
|
-
|
|
2430
|
-
DEPRECATED: kept one release for compatibility. Every host admin verb
|
|
2431
|
-
now has a tailored Typer command; pack-registered commands (``stalls``,
|
|
2432
|
-
``retry``, ...) resolve as dynamically generated subcommands. This
|
|
2433
|
-
generic string escape hatch hides from help and will be removed once
|
|
2434
|
-
scripts have migrated.
|
|
2435
|
-
"""
|
|
2436
|
-
console.print(
|
|
2437
|
-
"[yellow]`lisa run` is deprecated: host commands are real subcommands "
|
|
2438
|
-
"now (`lisa settings`, `lisa stalls`, ...).[/yellow]"
|
|
2439
|
-
)
|
|
2440
|
-
if not command:
|
|
2441
|
-
console.print("[red]No slash command given. Example: lisa run '/policy mode trust'[/red]")
|
|
2442
|
-
raise typer.Exit(code=2)
|
|
2443
|
-
parts = list(command)
|
|
2444
|
-
command_str = parts[0] if len(parts) == 1 else " ".join(shlex.quote(part) for part in parts)
|
|
2445
|
-
if not command_str.startswith("/"):
|
|
2446
|
-
command_str = "/" + command_str
|
|
2447
|
-
_run_host_command(
|
|
2448
|
-
command_str,
|
|
2449
|
-
log_level=log_level,
|
|
2450
|
-
log_path=log,
|
|
2451
|
-
no_log=no_log,
|
|
2452
|
-
data_dir=data_dir,
|
|
2453
|
-
profile=profile,
|
|
2454
|
-
overlays_dir=overlays_dir,
|
|
2455
|
-
thread_dir=thread_dir,
|
|
2456
|
-
no_thread=no_thread,
|
|
2457
|
-
session_context=session_context,
|
|
2458
|
-
timezone=timezone,
|
|
2459
|
-
state_snapshot_path=state_snapshot,
|
|
2460
|
-
streaming=streaming,
|
|
2461
|
-
host_log_path=host_log,
|
|
2462
|
-
)
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
2533
|
def _compose_prompt_message(instruction: str, payload: str) -> str:
|
|
2466
2534
|
"""Compose the prompt-mode turn message from the CLI instruction and stdin.
|
|
2467
2535
|
|
|
@@ -2672,14 +2740,22 @@ async def _run_prompt_with_teardown(
|
|
|
2672
2740
|
# stop — there is no turn to run.
|
|
2673
2741
|
decision_text = getattr(result, "text", "") or str(result)
|
|
2674
2742
|
transport.stop_activity()
|
|
2675
|
-
await runtime
|
|
2743
|
+
await _aclose_runtime_bounded(runtime)
|
|
2676
2744
|
await transport.on_response(decision_text)
|
|
2677
2745
|
return transport
|
|
2678
2746
|
if message:
|
|
2679
2747
|
await handle_message(runtime, message, transport)
|
|
2680
2748
|
finally:
|
|
2681
2749
|
transport.stop_activity()
|
|
2682
|
-
|
|
2750
|
+
# Bounded teardown: the prompt command previously awaited bare
|
|
2751
|
+
# ``runtime.aclose()`` — when the MCP pool's sequential anyio
|
|
2752
|
+
# task-group teardown stalls (the 2026-09-21 headroom incident
|
|
2753
|
+
# recurred on 2026-10-26 ~16:11: the turn had already completed
|
|
2754
|
+
# and persisted, yet the CLI sat idle in epoll for the rest of
|
|
2755
|
+
# the prompt run because the unbounded close never returned).
|
|
2756
|
+
# The bounded helper falls back to terminating every tracked
|
|
2757
|
+
# MCP/proxy subprocess after the grace window.
|
|
2758
|
+
await _aclose_runtime_bounded(runtime)
|
|
2683
2759
|
return transport
|
|
2684
2760
|
|
|
2685
2761
|
|
|
@@ -320,8 +320,7 @@ class PromptTransport:
|
|
|
320
320
|
# ── Turn counters ───────────────────────────────────────────
|
|
321
321
|
self._turn_started_perf: float = 0.0
|
|
322
322
|
self._current_mode: str = ""
|
|
323
|
-
self.
|
|
324
|
-
self._status_model_provider: str = ""
|
|
323
|
+
self._reset_model_status()
|
|
325
324
|
# Global thread turn number of the turn being rendered, stamped by
|
|
326
325
|
# the host on the ``model_call`` stage payload (``turn_index``).
|
|
327
326
|
# Rendered on the Response rule and the ⚡ footer; -1 = unknown. A
|
|
@@ -329,12 +328,7 @@ class PromptTransport:
|
|
|
329
328
|
# in ``start_thinking``: a pre-armed-decision run dispatches a second
|
|
330
329
|
# message on the same transport and each capture replaces it.
|
|
331
330
|
self._turn_number: int = -1
|
|
332
|
-
self.
|
|
333
|
-
self._status_prompt_tokens: int = 0
|
|
334
|
-
self._status_completion_tokens: int = 0
|
|
335
|
-
self._status_cached_tokens: int = 0
|
|
336
|
-
self._status_billed_tokens: int = 0
|
|
337
|
-
self._status_reasoning_chars: int = 0
|
|
331
|
+
self._init_status_counters()
|
|
338
332
|
self._iteration_durations: list[float] = []
|
|
339
333
|
self._model_call_count: int = 0
|
|
340
334
|
self._handoff_count: int = 0
|
|
@@ -382,6 +376,43 @@ class PromptTransport:
|
|
|
382
376
|
# ── Stream velocity (observable, not manifest-trusted) ──────
|
|
383
377
|
self._init_velocity_state()
|
|
384
378
|
|
|
379
|
+
def _init_status_counters(self) -> None:
|
|
380
|
+
"""Zero the per-turn token/cost counters shared by the footer.
|
|
381
|
+
|
|
382
|
+
``start_thinking`` re-runs this for each turn; constructing the
|
|
383
|
+
transport runs it once for the pre-turn state.
|
|
384
|
+
|
|
385
|
+
``_status_turn_tokens`` is the *walk* (every call's ``total_tokens``),
|
|
386
|
+
``_status_billed_tokens`` the uncached basis, and the two cost fields
|
|
387
|
+
carry the kernel's canonical estimate and the provider's own reported
|
|
388
|
+
charge. The provider figure is authoritative when non-zero; when the
|
|
389
|
+
kernel supplied neither cost field the footer falls back to pricing
|
|
390
|
+
``model_prices`` itself, so an older runtime still shows a number.
|
|
391
|
+
"""
|
|
392
|
+
self._status_turn_tokens: int = 0
|
|
393
|
+
self._status_prompt_tokens: int = 0
|
|
394
|
+
self._status_completion_tokens: int = 0
|
|
395
|
+
self._status_cached_tokens: int = 0
|
|
396
|
+
self._status_billed_tokens: int = 0
|
|
397
|
+
self._status_cost_usd: float = 0.0
|
|
398
|
+
self._status_provider_cost_usd: float = 0.0
|
|
399
|
+
# Model calls that reported a provider charge. A turn mixing
|
|
400
|
+
# providers (some reporting cost, some silent) has a partial total,
|
|
401
|
+
# which must not masquerade as the full bill.
|
|
402
|
+
self._status_provider_cost_calls: int = 0
|
|
403
|
+
# Per-turn sums of the counters the serving proxy declared on its
|
|
404
|
+
# response headers (e.g. compression ``tokens_saved``), keyed by the
|
|
405
|
+
# pack's own field names. Empty when no proxy is in the path — the
|
|
406
|
+
# footer then renders no proxy segment at all.
|
|
407
|
+
self._status_proxy_telemetry: dict[str, int] = {}
|
|
408
|
+
self._status_reasoning_chars: int = 0
|
|
409
|
+
|
|
410
|
+
def _reset_model_status(self) -> None:
|
|
411
|
+
self._status_current_model: str = ""
|
|
412
|
+
self._status_model_provider: str = ""
|
|
413
|
+
self._status_proxy_id: str = ""
|
|
414
|
+
self._status_proxy_via: str = ""
|
|
415
|
+
|
|
385
416
|
def _init_velocity_state(self) -> None:
|
|
386
417
|
"""Initialize the velocity-sampling state (one statement block).
|
|
387
418
|
|
|
@@ -458,12 +489,15 @@ class PromptTransport:
|
|
|
458
489
|
self._status_completion_tokens = 0
|
|
459
490
|
self._status_cached_tokens = 0
|
|
460
491
|
self._status_billed_tokens = 0
|
|
492
|
+
self._status_cost_usd = 0.0
|
|
493
|
+
self._status_provider_cost_usd = 0.0
|
|
494
|
+
self._status_provider_cost_calls = 0
|
|
495
|
+
self._status_proxy_telemetry = {}
|
|
461
496
|
self._status_reasoning_chars = 0
|
|
462
497
|
self._fork_children = {}
|
|
463
498
|
self._fork_total = 0
|
|
464
499
|
self._current_mode = ""
|
|
465
|
-
self.
|
|
466
|
-
self._status_model_provider = ""
|
|
500
|
+
self._reset_model_status()
|
|
467
501
|
self._active_label = ""
|
|
468
502
|
self._phase_label = "starting runtime"
|
|
469
503
|
self._last_tool_data = None
|
|
@@ -755,6 +789,24 @@ class PromptTransport:
|
|
|
755
789
|
if isinstance(turn_raw, int) and turn_raw >= 0:
|
|
756
790
|
self._turn_number = turn_raw
|
|
757
791
|
|
|
792
|
+
def _capture_proxy_route(self, data: dict[str, Any]) -> None:
|
|
793
|
+
"""Capture the provider's proxy routing from a stage payload.
|
|
794
|
+
|
|
795
|
+
``proxy_route.via`` is ``"proxy"`` (requests flow through the named
|
|
796
|
+
proxy pack, e.g. headroom) or ``"direct"``. A payload without the
|
|
797
|
+
key clears the chip: transports must not keep showing a stale hop
|
|
798
|
+
when the runtime stops stamping routing info.
|
|
799
|
+
"""
|
|
800
|
+
route = data.get("proxy_route")
|
|
801
|
+
if not isinstance(route, dict):
|
|
802
|
+
self._status_proxy_id = ""
|
|
803
|
+
self._status_proxy_via = ""
|
|
804
|
+
return
|
|
805
|
+
via = str(route.get("via") or "")
|
|
806
|
+
self._status_proxy_via = via if via in ("proxy", "direct") else ""
|
|
807
|
+
proxy_id = route.get("proxy_id") if via == "proxy" else route.get("bypassed_proxy_id")
|
|
808
|
+
self._status_proxy_id = str(proxy_id or "")
|
|
809
|
+
|
|
758
810
|
def _handle_model_stage(self, label: str, data: dict[str, Any]) -> None:
|
|
759
811
|
"""Route model_call* stage events.
|
|
760
812
|
|
|
@@ -770,6 +822,7 @@ class PromptTransport:
|
|
|
770
822
|
provider = data.get("provider")
|
|
771
823
|
self._status_current_model = str(model_id)
|
|
772
824
|
self._status_model_provider = str(provider) if provider not in (None, "") else ""
|
|
825
|
+
self._capture_proxy_route(data)
|
|
773
826
|
self._model_call_count += 1
|
|
774
827
|
self._active_reasoning_model = bool(data.get("reasoning_model"))
|
|
775
828
|
# A new model call resets the stall clock: pre-call silence is
|
|
@@ -790,28 +843,7 @@ class PromptTransport:
|
|
|
790
843
|
if d_raw not in (None, "", 0):
|
|
791
844
|
with contextlib.suppress(TypeError, ValueError):
|
|
792
845
|
self._iteration_durations.append(float(d_raw))
|
|
793
|
-
|
|
794
|
-
if total_raw not in (None, "", 0):
|
|
795
|
-
self._status_turn_tokens += int(total_raw)
|
|
796
|
-
# Split counts feed the footer's cost estimate (manifest prices
|
|
797
|
-
# are per prompt/completion token). Cache-hit counts let the
|
|
798
|
-
# estimate price cached prompts at the manifest's cache-read rate.
|
|
799
|
-
with contextlib.suppress(TypeError, ValueError):
|
|
800
|
-
self._status_prompt_tokens += int(data.get("prompt_tokens") or 0)
|
|
801
|
-
with contextlib.suppress(TypeError, ValueError):
|
|
802
|
-
self._status_completion_tokens += int(data.get("completion_tokens") or 0)
|
|
803
|
-
cached_now = 0
|
|
804
|
-
with contextlib.suppress(TypeError, ValueError):
|
|
805
|
-
cached_now = int(data.get("cached_tokens") or 0)
|
|
806
|
-
self._status_cached_tokens += cached_now
|
|
807
|
-
with contextlib.suppress(TypeError, ValueError):
|
|
808
|
-
prompt_now = int(data.get("prompt_tokens") or 0)
|
|
809
|
-
# Billed basis (2026-10-12 perf review): the walk figure counts
|
|
810
|
-
# every cache hit at full size; the operator-facing "what did
|
|
811
|
-
# this turn actually cost" is uncached prompt + completion.
|
|
812
|
-
self._status_billed_tokens += max(0, prompt_now - cached_now) + int(
|
|
813
|
-
data.get("completion_tokens") or 0
|
|
814
|
-
)
|
|
846
|
+
self._accumulate_call_usage(data)
|
|
815
847
|
return
|
|
816
848
|
if label == "model_call_failed":
|
|
817
849
|
self._print_event_line(
|
|
@@ -1263,7 +1295,7 @@ class PromptTransport:
|
|
|
1263
1295
|
return body
|
|
1264
1296
|
|
|
1265
1297
|
def _status_leading_parts(self) -> list[Text]:
|
|
1266
|
-
"""Leading status segments: turn, mode, model, in-flight label."""
|
|
1298
|
+
"""Leading status segments: turn, mode, model, proxy hop, in-flight label."""
|
|
1267
1299
|
parts: list[Text] = []
|
|
1268
1300
|
if self._turn_number >= 0:
|
|
1269
1301
|
parts.append(Text(f"turn {self._turn_number}", style=STYLE_ROW_MODEL))
|
|
@@ -1274,10 +1306,35 @@ class PromptTransport:
|
|
|
1274
1306
|
if self._status_model_provider:
|
|
1275
1307
|
model = f"{self._status_model_provider}:{model}"
|
|
1276
1308
|
parts.append(Text(model, style=STYLE_STAGE))
|
|
1309
|
+
chip = self._proxy_chip_part()
|
|
1310
|
+
if chip is not None:
|
|
1311
|
+
parts.append(chip)
|
|
1277
1312
|
if self._active_label:
|
|
1278
1313
|
parts.append(Text(_truncate(self._active_label, 48), style=STYLE_STAGE))
|
|
1279
1314
|
return parts
|
|
1280
1315
|
|
|
1316
|
+
def _proxy_chip_part(self) -> Text | None:
|
|
1317
|
+
"""Proxy-pack routing chip, or ``None`` when the host stamped nothing.
|
|
1318
|
+
|
|
1319
|
+
``⤳ <proxy_id>`` when the provider routes through a ready proxy
|
|
1320
|
+
(e.g. headroom); nothing when unstamped or direct with no proxy
|
|
1321
|
+
declared. The chip keeps the model segment honest about where
|
|
1322
|
+
requests actually flow.
|
|
1323
|
+
"""
|
|
1324
|
+
if not self._status_proxy_via:
|
|
1325
|
+
return None
|
|
1326
|
+
if self._status_proxy_via == "proxy" and self._status_proxy_id:
|
|
1327
|
+
proxy_id = self._status_proxy_id
|
|
1328
|
+
prefix, separator, suffix = proxy_id.partition(".")
|
|
1329
|
+
if separator and prefix == suffix:
|
|
1330
|
+
proxy_id = prefix
|
|
1331
|
+
return Text(f"⤳ {_truncate(proxy_id, 24)}", style=STYLE_STAGE)
|
|
1332
|
+
if self._status_proxy_via == "direct" and self._status_proxy_id:
|
|
1333
|
+
return Text(
|
|
1334
|
+
f"{_truncate(self._status_proxy_id, 24)} ↓ direct", style=STYLE_ROW_TRANSITION
|
|
1335
|
+
)
|
|
1336
|
+
return None
|
|
1337
|
+
|
|
1281
1338
|
def _append_meter_parts(self, parts: list[Text]) -> None:
|
|
1282
1339
|
"""Append the thinking meter segment when armed.
|
|
1283
1340
|
|
|
@@ -1396,20 +1453,126 @@ class PromptTransport:
|
|
|
1396
1453
|
if self._status_turn_tokens:
|
|
1397
1454
|
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1398
1455
|
footer.append(f"{_format_tokens(self._status_turn_tokens)} tok", style="green3")
|
|
1399
|
-
#
|
|
1400
|
-
#
|
|
1401
|
-
# actually
|
|
1402
|
-
|
|
1456
|
+
# Billed segment: the walk above counts every cache hit at full
|
|
1457
|
+
# size, which on a cache-heavy turn reads up to 30x the tokens the
|
|
1458
|
+
# provider actually bills. Always show the billed basis beside it
|
|
1459
|
+
# when the two differ — that is the number to sanity-check spend
|
|
1460
|
+
# against. When the turn had no cache hits the two are identical
|
|
1461
|
+
# and the segment would be noise.
|
|
1462
|
+
if 0 < self._status_billed_tokens < self._status_turn_tokens:
|
|
1403
1463
|
footer.append(
|
|
1404
1464
|
f" · billed {_format_tokens(self._status_billed_tokens)}",
|
|
1405
1465
|
style="cyan",
|
|
1406
1466
|
)
|
|
1407
|
-
cost = self.
|
|
1467
|
+
cost, authoritative = self._resolve_turn_cost()
|
|
1408
1468
|
if cost:
|
|
1409
1469
|
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1410
|
-
|
|
1470
|
+
# ``$`` marks a provider-reported charge (the real meter); the
|
|
1471
|
+
# ``~`` prefix marks the harness's own estimate from manifest
|
|
1472
|
+
# rates, so the two are never mistaken for each other.
|
|
1473
|
+
prefix = "$" if authoritative else "~$"
|
|
1474
|
+
footer.append(f"{prefix}{cost:g}", style="cyan")
|
|
1475
|
+
self._append_proxy_savings(footer)
|
|
1411
1476
|
self.console.print(footer)
|
|
1412
1477
|
|
|
1478
|
+
def _append_proxy_savings(self, footer: Text) -> None:
|
|
1479
|
+
"""Append the serving proxy's reported savings, when it declared any.
|
|
1480
|
+
|
|
1481
|
+
A proxy in the path (compression, cache steering) reports what it
|
|
1482
|
+
saved per call on response headers; the kernel sums them and the
|
|
1483
|
+
footer shows the turn's total. The counter is whatever the proxy
|
|
1484
|
+
declared, so nothing is rendered unless a known name is present —
|
|
1485
|
+
an unrecognised counter still reaches ``lisa tokens`` and any pack
|
|
1486
|
+
reading the telemetry log, but the one-line footer only labels the
|
|
1487
|
+
fields it can describe without guessing at units.
|
|
1488
|
+
"""
|
|
1489
|
+
|
|
1490
|
+
saved = self._status_proxy_telemetry.get("tokens_saved")
|
|
1491
|
+
if not saved:
|
|
1492
|
+
return
|
|
1493
|
+
footer.append(" · ", style=STYLE_TEXT_MUTED)
|
|
1494
|
+
footer.append(f"saved {_format_tokens(saved)}", style="dark_sea_green4")
|
|
1495
|
+
|
|
1496
|
+
def _accumulate_call_usage(self, data: dict[str, Any]) -> None:
|
|
1497
|
+
"""Fold one completed model call's usage into the turn totals.
|
|
1498
|
+
|
|
1499
|
+
Three figures accumulate independently, because they answer different
|
|
1500
|
+
questions and must not be conflated:
|
|
1501
|
+
|
|
1502
|
+
* ``_status_turn_tokens`` — the *walk*: sum of every call's
|
|
1503
|
+
``total_tokens``. Grows quadratically across an agentic turn
|
|
1504
|
+
because each iteration re-sends the whole conversation. Useful for
|
|
1505
|
+
"how much context did this turn chew through", misleading as a bill.
|
|
1506
|
+
* ``_status_billed_tokens`` — uncached prompt + completion, i.e. the
|
|
1507
|
+
tokens the provider charges at full rate.
|
|
1508
|
+
* ``_status_cost_usd`` / ``_status_provider_cost_usd`` — the kernel's
|
|
1509
|
+
canonical estimate and the provider's own reported charge.
|
|
1510
|
+
"""
|
|
1511
|
+
|
|
1512
|
+
total_raw = data.get("_state.total_tokens") or data.get("total_tokens")
|
|
1513
|
+
if total_raw not in (None, "", 0):
|
|
1514
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1515
|
+
self._status_turn_tokens += int(total_raw)
|
|
1516
|
+
prompt_now = 0
|
|
1517
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1518
|
+
prompt_now = int(data.get("prompt_tokens") or 0)
|
|
1519
|
+
self._status_prompt_tokens += prompt_now
|
|
1520
|
+
completion_now = 0
|
|
1521
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1522
|
+
completion_now = int(data.get("completion_tokens") or 0)
|
|
1523
|
+
self._status_completion_tokens += completion_now
|
|
1524
|
+
cached_now = 0
|
|
1525
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1526
|
+
cached_now = int(data.get("cached_tokens") or 0)
|
|
1527
|
+
self._status_cached_tokens += cached_now
|
|
1528
|
+
self._status_billed_tokens += max(0, prompt_now - cached_now) + completion_now
|
|
1529
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1530
|
+
self._status_cost_usd += float(data.get("cost_usd") or 0.0)
|
|
1531
|
+
provider_now = 0.0
|
|
1532
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1533
|
+
provider_now = float(data.get("provider_cost_usd") or 0.0)
|
|
1534
|
+
if provider_now > 0.0:
|
|
1535
|
+
self._status_provider_cost_usd += provider_now
|
|
1536
|
+
self._status_provider_cost_calls += 1
|
|
1537
|
+
# Proxy counters are summed without interpreting them: the key set is
|
|
1538
|
+
# whatever the serving proxy declared, and the footer renders only the
|
|
1539
|
+
# ones it knows how to label.
|
|
1540
|
+
telemetry = data.get("proxy_telemetry")
|
|
1541
|
+
if isinstance(telemetry, dict):
|
|
1542
|
+
for key, raw in telemetry.items():
|
|
1543
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
1544
|
+
self._status_proxy_telemetry[str(key)] = self._status_proxy_telemetry.get(
|
|
1545
|
+
str(key), 0
|
|
1546
|
+
) + max(0, int(raw))
|
|
1547
|
+
|
|
1548
|
+
def _resolve_turn_cost(self) -> tuple[float, bool]:
|
|
1549
|
+
"""Return ``(usd, authoritative)`` for the turn's cost segment.
|
|
1550
|
+
|
|
1551
|
+
Three sources, most trustworthy first:
|
|
1552
|
+
|
|
1553
|
+
1. ``_status_provider_cost_usd`` — the provider's own reported
|
|
1554
|
+
charge (OpenRouter ``usage.cost``). Authoritative: it already
|
|
1555
|
+
reflects the provider's real cache read/write rates and any
|
|
1556
|
+
surcharges, so nothing here can be more accurate.
|
|
1557
|
+
2. ``_status_cost_usd`` — the kernel's canonical estimate from the
|
|
1558
|
+
manifest's per-token rates. Correct but list-price only.
|
|
1559
|
+
3. ``_estimate_turn_cost()`` — this transport pricing the counts it
|
|
1560
|
+
accumulated itself. Kept for runtimes whose stage payload predates
|
|
1561
|
+
the cost fields, so an older kernel still yields a figure.
|
|
1562
|
+
|
|
1563
|
+
A provider charge wins only when *every* call in the turn reported
|
|
1564
|
+
one; a partial sum would understate the bill, so mixed turns fall
|
|
1565
|
+
back to the complete canonical estimate.
|
|
1566
|
+
"""
|
|
1567
|
+
|
|
1568
|
+
calls = self._model_call_count
|
|
1569
|
+
provider_total = self._status_provider_cost_usd
|
|
1570
|
+
if provider_total > 0.0 and calls > 0 and self._status_provider_cost_calls == calls:
|
|
1571
|
+
return round(provider_total, 4), True
|
|
1572
|
+
if self._status_cost_usd > 0.0:
|
|
1573
|
+
return round(self._status_cost_usd, 4), False
|
|
1574
|
+
return self._estimate_turn_cost(), False
|
|
1575
|
+
|
|
1413
1576
|
def _estimate_turn_cost(self) -> float:
|
|
1414
1577
|
"""Estimate the turn's USD cost from the manifest per-token prices.
|
|
1415
1578
|
|
|
@@ -27,8 +27,8 @@ tests/test_prompt_transport.py
|
|
|
27
27
|
tests/test_render_catalogs.py
|
|
28
28
|
tests/test_render_models.py
|
|
29
29
|
tests/test_restart.py
|
|
30
|
-
tests/test_run_command.py
|
|
31
30
|
tests/test_runtime_teardown.py
|
|
32
31
|
tests/test_streaming.py
|
|
33
32
|
tests/test_transport_stage.py
|
|
33
|
+
tests/test_update_command.py
|
|
34
34
|
tests/test_verify_command.py
|