lighthouse-cli 0.16.0__py3-none-any.whl
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.
- lighthouse/__init__.py +6 -0
- lighthouse/__main__.py +9 -0
- lighthouse/commands/__init__.py +0 -0
- lighthouse/commands/agents.py +310 -0
- lighthouse/commands/builds.py +85 -0
- lighthouse/commands/collectors.py +147 -0
- lighthouse/commands/connect.py +717 -0
- lighthouse/commands/connections.py +201 -0
- lighthouse/commands/evals.py +198 -0
- lighthouse/commands/kb.py +224 -0
- lighthouse/commands/language.py +56 -0
- lighthouse/commands/limits.py +61 -0
- lighthouse/commands/login.py +207 -0
- lighthouse/commands/models.py +61 -0
- lighthouse/commands/runs.py +157 -0
- lighthouse/commands/spend.py +84 -0
- lighthouse/commands/stack.py +103 -0
- lighthouse/commands/terms.py +50 -0
- lighthouse/commands/whoami.py +80 -0
- lighthouse/config.py +141 -0
- lighthouse/credentials.py +233 -0
- lighthouse/device.py +124 -0
- lighthouse/exits.py +78 -0
- lighthouse/flags.py +61 -0
- lighthouse/http.py +355 -0
- lighthouse/i18n.py +250 -0
- lighthouse/locales/ar.json +343 -0
- lighthouse/locales/de.json +343 -0
- lighthouse/locales/en.json +343 -0
- lighthouse/locales/es.json +343 -0
- lighthouse/locales/fr.json +343 -0
- lighthouse/locales/ga.json +343 -0
- lighthouse/locales/it.json +343 -0
- lighthouse/locales/ja.json +343 -0
- lighthouse/locales/nl.json +343 -0
- lighthouse/locales/pt.json +343 -0
- lighthouse/locales/zh-Hans.json +343 -0
- lighthouse/locales/zh-Hant.json +343 -0
- lighthouse/main.py +229 -0
- lighthouse/out.py +169 -0
- lighthouse/py.typed +0 -0
- lighthouse/sealing.py +448 -0
- lighthouse_cli-0.16.0.dist-info/METADATA +23 -0
- lighthouse_cli-0.16.0.dist-info/RECORD +49 -0
- lighthouse_cli-0.16.0.dist-info/WHEEL +5 -0
- lighthouse_cli-0.16.0.dist-info/entry_points.txt +2 -0
- lighthouse_cli-0.16.0.dist-info/licenses/LICENSE +202 -0
- lighthouse_cli-0.16.0.dist-info/licenses/NOTICE +2 -0
- lighthouse_cli-0.16.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse language`: the language this CLI, the portal, MCP and the
|
|
4
|
+
assistant speak to you (contract 1.29).
|
|
5
|
+
|
|
6
|
+
lighthouse language your setting, your organisation's, and what is in use
|
|
7
|
+
lighthouse language --list only the languages on offer
|
|
8
|
+
|
|
9
|
+
Read only. A person sets their own language in the portal under Preferences
|
|
10
|
+
(and a client administrator the organisation's under Settings): a delegated
|
|
11
|
+
token reads `/api/v1/me/preferences` but never writes it, as it never writes
|
|
12
|
+
anything but a deploy and a collector request (`oauth.ROUTE_SCOPES`). For one
|
|
13
|
+
run, `--lang` or `LIGHTHOUSE_LANG` wins. The effective language is kept
|
|
14
|
+
beside the profile file so the next run speaks it without a request (i18n.py).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from lighthouse import i18n
|
|
22
|
+
from lighthouse.main import Command, Context
|
|
23
|
+
from lighthouse.out import Out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
27
|
+
ap.add_argument("--list", action="store_true", dest="list_all", help="only list the languages on offer")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def language(ctx: Context) -> dict[str, Any]:
|
|
31
|
+
got = ctx.client().get("/api/v1/me/preferences")
|
|
32
|
+
effective = (got.get("effective") or {}).get("language")
|
|
33
|
+
i18n.remember(ctx.config.path.parent, ctx.host, effective)
|
|
34
|
+
return {"ok": True, "language": (got.get("preferences") or {}).get("language"),
|
|
35
|
+
"tenant_language": (got.get("tenant") or {}).get("language"),
|
|
36
|
+
"effective": effective, "in_use": i18n.current(),
|
|
37
|
+
"list_only": bool(ctx.args.list_all),
|
|
38
|
+
"supported_languages": got.get("supported_languages") or []}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
42
|
+
names = {x["tag"]: x["name_native"] for x in body["supported_languages"]}
|
|
43
|
+
|
|
44
|
+
def named(tag: str | None) -> str | None:
|
|
45
|
+
return None if tag is None else f"{tag} {names.get(tag, '')}".rstrip()
|
|
46
|
+
|
|
47
|
+
if not body.get("list_only"):
|
|
48
|
+
out.pairs([("your language", named(body["language"])),
|
|
49
|
+
("your organisation's", named(body["tenant_language"])),
|
|
50
|
+
("in use now", named(body["in_use"]))])
|
|
51
|
+
out.say("")
|
|
52
|
+
out.table(["tag", "language"], [[x["tag"], x["name_native"]] for x in body["supported_languages"]])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
COMMANDS = (Command("language", "your language, and the languages on offer",
|
|
56
|
+
_configure, language, _show),)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse limits`: the spend limits in force, and whether they reached the engines.
|
|
4
|
+
|
|
5
|
+
lighthouse limits
|
|
6
|
+
lighthouse limits --json
|
|
7
|
+
|
|
8
|
+
Reads `GET /api/v1/limits`: each limit's scope (`scope_kind` and `scope`),
|
|
9
|
+
its period, its cap (`cap_usd`), what has been spent against it (`spent_usd`,
|
|
10
|
+
`pct`), its alert thresholds, and **whether each deployment has applied it**
|
|
11
|
+
(`deliveries`) — which is the column that matters,
|
|
12
|
+
because a limit the engine has not picked up is not yet a limit.
|
|
13
|
+
|
|
14
|
+
Limits are **read only** from the command line. Raising a cap is a payment
|
|
15
|
+
decision, and a delegated credential never makes one
|
|
16
|
+
(`docs/adr/0012-delegated-credentials.md`); change them in the portal, where
|
|
17
|
+
the change is audited against a person.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from lighthouse.main import Command, Context
|
|
25
|
+
from lighthouse.out import Out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
29
|
+
ap.add_argument("--scope", help="only limits of this scope kind")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def limits(ctx: Context) -> dict[str, Any]:
|
|
33
|
+
body = ctx.client().get("/api/v1/limits")
|
|
34
|
+
rows = body.get("limits") or []
|
|
35
|
+
if ctx.args.scope:
|
|
36
|
+
rows = [r for r in rows if r.get("scope_kind") == ctx.args.scope]
|
|
37
|
+
return {"ok": True, **body, "limits": rows}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _applied(limit: dict[str, Any]) -> str:
|
|
41
|
+
"""`applied/total` deployments. A limit that is enforced by LightHouse
|
|
42
|
+
itself is never sent to a gateway, so it has no deliveries to count."""
|
|
43
|
+
if limit.get("enforced_by") == "platform":
|
|
44
|
+
return "LightHouse"
|
|
45
|
+
sent = limit.get("deliveries") or []
|
|
46
|
+
return f"{sum(1 for d in sent if d.get('status') == 'applied')}/{len(sent)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
50
|
+
out.table(["scope", "what", "period", "cap", "spent", "used %", "state", "alerts at %",
|
|
51
|
+
"applied by"],
|
|
52
|
+
[[r.get("scope_kind"), r.get("scope"), r.get("period"), r.get("cap_usd"),
|
|
53
|
+
r.get("spent_usd"), r.get("pct"), r.get("state"),
|
|
54
|
+
", ".join(str(a) for a in r.get("alert_pcts") or []), _applied(r)]
|
|
55
|
+
for r in body["limits"]])
|
|
56
|
+
out.note("a limit no deployment has applied is not yet in force; "
|
|
57
|
+
"change limits in the portal, where the change is audited")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
COMMANDS = (Command("limits", "the spend limits in force, and whether the engines applied them",
|
|
61
|
+
_configure, limits, _show),)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse login`, `logout` and `tokens`.
|
|
4
|
+
|
|
5
|
+
lighthouse login --host t7m2kqx9vbn.atlanticagenticlabs.com
|
|
6
|
+
lighthouse login --scope "telemetry:read agents:read knowledge:read"
|
|
7
|
+
lighthouse logout [--all]
|
|
8
|
+
lighthouse tokens # every delegated token of mine, and revoke one
|
|
9
|
+
|
|
10
|
+
`login` runs the device flow (device.py), stores the token in the OS keychain
|
|
11
|
+
(credentials.py), remembers the host in the profile file (config.py), and then
|
|
12
|
+
reads `/api/v1/me` so it can say who you are rather than only that it worked.
|
|
13
|
+
|
|
14
|
+
Where the tenant sits behind Cloudflare Access, `login` can keep the tenant's
|
|
15
|
+
Access service token in the keychain with the delegated token:
|
|
16
|
+
`--access-client-id ID` and the secret from `--access-client-secret-stdin` (one
|
|
17
|
+
line on standard input) or `LIGHTHOUSE_ACCESS_CLIENT_SECRET`. The secret is never
|
|
18
|
+
a command-line argument, where `ps` and shell history would keep it. The OAuth
|
|
19
|
+
endpoints are not behind Access, so the sign-in itself needs none; the calls
|
|
20
|
+
after it do.
|
|
21
|
+
|
|
22
|
+
For a pipeline use a machine key instead: `LIGHTHOUSE_TOKEN=aalcp-...` with
|
|
23
|
+
`LIGHTHOUSE_ACCESS_CLIENT_ID` and `LIGHTHOUSE_ACCESS_CLIENT_SECRET`. A delegated
|
|
24
|
+
token lasts eight hours and is not refreshed under `LIGHTHOUSE_TOKEN`.
|
|
25
|
+
|
|
26
|
+
It never prints the token. Under `--json` the document carries the person, the
|
|
27
|
+
host, the scopes and the expiry — and no credential, which `tests/test_out.py`
|
|
28
|
+
and the golden test both hold it to.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import sys
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from lighthouse import device
|
|
37
|
+
from lighthouse.credentials import ENV_ACCESS_ID, ENV_ACCESS_SECRET, access_from_env
|
|
38
|
+
from lighthouse.exits import FORBIDDEN, USAGE, CliError
|
|
39
|
+
from lighthouse.http import Refused
|
|
40
|
+
from lighthouse.i18n import t
|
|
41
|
+
from lighthouse.main import Command, Context
|
|
42
|
+
from lighthouse.out import Out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _configure_login(ap: argparse.ArgumentParser) -> None:
|
|
46
|
+
ap.add_argument("--scope", help="the scopes to ask for, space separated "
|
|
47
|
+
"(default: telemetry:read agents:read)")
|
|
48
|
+
ap.add_argument("--no-browser", action="store_true", dest="no_browser",
|
|
49
|
+
help="print the URL instead of opening a browser")
|
|
50
|
+
ap.add_argument("--name", default="the lighthouse CLI",
|
|
51
|
+
help="how this sign-in is described in the portal and the audit log")
|
|
52
|
+
ap.add_argument("--access-client-id", dest="access_client_id", metavar="ID",
|
|
53
|
+
help="the tenant's Cloudflare Access service token id, to keep with "
|
|
54
|
+
"the sign-in (the secret: --access-client-secret-stdin)")
|
|
55
|
+
ap.add_argument("--access-client-secret-stdin", action="store_true",
|
|
56
|
+
dest="access_secret_stdin",
|
|
57
|
+
help="read the Access service token secret from one line of standard input")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _access(ctx: Context) -> tuple[str, str] | None:
|
|
61
|
+
"""The Access service token for this login: the flag and stdin, else the
|
|
62
|
+
environment. Never a secret on the command line."""
|
|
63
|
+
given = (getattr(ctx.args, "access_client_id", None) or "").strip()
|
|
64
|
+
if getattr(ctx.args, "access_secret_stdin", False):
|
|
65
|
+
secret = ((ctx.stdin if ctx.stdin is not None else sys.stdin).readline()).strip()
|
|
66
|
+
if not secret:
|
|
67
|
+
raise CliError(t("err.access_secret_empty"), code=USAGE, error="usage")
|
|
68
|
+
if not given:
|
|
69
|
+
given = (ctx.env.get(ENV_ACCESS_ID) or "").strip()
|
|
70
|
+
if not given:
|
|
71
|
+
raise CliError(t("err.access_id_missing"), code=USAGE, error="usage",
|
|
72
|
+
hint=t("hint.access_id_missing", id=ENV_ACCESS_ID))
|
|
73
|
+
return given, secret
|
|
74
|
+
if given:
|
|
75
|
+
secret = (ctx.env.get(ENV_ACCESS_SECRET) or "").strip()
|
|
76
|
+
if not secret:
|
|
77
|
+
raise CliError(t("err.access_secret_missing"), code=USAGE, error="usage",
|
|
78
|
+
hint=t("hint.access_secret_missing", secret=ENV_ACCESS_SECRET))
|
|
79
|
+
return given, secret
|
|
80
|
+
return access_from_env(ctx.env)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def login(ctx: Context) -> dict[str, Any]:
|
|
84
|
+
host = ctx.host
|
|
85
|
+
access = _access(ctx)
|
|
86
|
+
anonymous = ctx.new_client(host, None, access=access)
|
|
87
|
+
try:
|
|
88
|
+
token = device.sign_in(
|
|
89
|
+
anonymous, out=ctx.out, scope=getattr(ctx.args, "scope", None),
|
|
90
|
+
client_name=ctx.args.name, no_browser=bool(ctx.args.no_browser),
|
|
91
|
+
assume_yes=bool(getattr(ctx.args, "yes", False)),
|
|
92
|
+
no_input=bool(getattr(ctx.args, "no_input", False)),
|
|
93
|
+
opener=ctx.opener, sleep=ctx.sleep, reader=ctx.reader)
|
|
94
|
+
finally:
|
|
95
|
+
anonymous.close()
|
|
96
|
+
access_id, access_secret = access if access else (None, None)
|
|
97
|
+
credential = ctx.store.save(host, token, access_client_id=access_id,
|
|
98
|
+
access_client_secret=access_secret)
|
|
99
|
+
ctx.config.remember(host)
|
|
100
|
+
with ctx.new_client(host, credential) as client:
|
|
101
|
+
me = client.get("/api/v1/me")
|
|
102
|
+
viewer = me.get("viewer") or {}
|
|
103
|
+
credential = ctx.store.save(host, token, email=viewer.get("email"),
|
|
104
|
+
access_client_id=access_id, access_client_secret=access_secret)
|
|
105
|
+
return {"ok": True, "host": host, "access_service_token": access is not None,
|
|
106
|
+
"client": (me.get("client") or {}).get("name"),
|
|
107
|
+
"email": viewer.get("email"), "role": viewer.get("role"),
|
|
108
|
+
"scopes": list(credential.scopes),
|
|
109
|
+
"expires_at": credential.expires_at.isoformat() if credential.expires_at else None}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _show_login(out: Out, body: dict[str, Any]) -> None:
|
|
113
|
+
out.done(t("login.signed_in", tenant=body["client"] or body["host"], email=body["email"],
|
|
114
|
+
role=body["role"]))
|
|
115
|
+
out.note(t("login.scopes", scopes=" ".join(body["scopes"]) or t("login.no_scopes")))
|
|
116
|
+
out.note(t("login.in_keychain"))
|
|
117
|
+
if body.get("access_service_token"):
|
|
118
|
+
out.note(t("login.access_kept"))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _configure_logout(ap: argparse.ArgumentParser) -> None:
|
|
122
|
+
ap.add_argument("--all", action="store_true", dest="every",
|
|
123
|
+
help="forget every host, not only the current one")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def logout(ctx: Context) -> dict[str, Any]:
|
|
127
|
+
"""Revoke at the service *and* forget locally.
|
|
128
|
+
|
|
129
|
+
Revoking first matters: a credential only deleted from this machine is
|
|
130
|
+
still live until it expires, and `logout` is exactly what somebody runs on
|
|
131
|
+
a laptop they are handing back.
|
|
132
|
+
"""
|
|
133
|
+
hosts = sorted({p.host for p in ctx.config.profiles.values()}) \
|
|
134
|
+
if getattr(ctx.args, "every", False) else [ctx.host]
|
|
135
|
+
out = []
|
|
136
|
+
for host in hosts:
|
|
137
|
+
credential = ctx.store.load(host)
|
|
138
|
+
revoked = False
|
|
139
|
+
if credential is not None and credential.source == "keychain":
|
|
140
|
+
with ctx.new_client(host, credential) as client:
|
|
141
|
+
try:
|
|
142
|
+
client.post("/api/v1/oauth/revoke",
|
|
143
|
+
{"token": credential.refresh_token or credential.access_token})
|
|
144
|
+
revoked = True
|
|
145
|
+
except CliError as e:
|
|
146
|
+
ctx.out.warn(t("logout.could_not_revoke", host=host, error=e.error))
|
|
147
|
+
ctx.store.forget(host)
|
|
148
|
+
ctx.config.forget(host)
|
|
149
|
+
out.append({"host": host, "revoked": revoked,
|
|
150
|
+
"had_credential": credential is not None})
|
|
151
|
+
return {"ok": True, "logged_out": out}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _show_logout(out: Out, body: dict[str, Any]) -> None:
|
|
155
|
+
for row in body["logged_out"]:
|
|
156
|
+
if not row["had_credential"]:
|
|
157
|
+
out.say(t("logout.nothing_stored", host=row["host"]))
|
|
158
|
+
elif row["revoked"]:
|
|
159
|
+
out.done(t("logout.revoked", host=row["host"]))
|
|
160
|
+
else:
|
|
161
|
+
out.say(t("logout.forgotten", host=row["host"]))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _configure_tokens(ap: argparse.ArgumentParser) -> None:
|
|
165
|
+
ap.add_argument("--revoke", metavar="TOKEN_ID",
|
|
166
|
+
help="revoke one of your own tokens by its id")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def tokens(ctx: Context) -> dict[str, Any]:
|
|
170
|
+
credential = ctx.store.require(ctx.host)
|
|
171
|
+
if credential.kind == "machine":
|
|
172
|
+
# A machine key has no person behind it, so no delegated tokens of its
|
|
173
|
+
# own: the service answers 403 invalid_client. Say so without asking it.
|
|
174
|
+
raise CliError(t("err.machine_key_no_tokens"), code=FORBIDDEN, error="machine_key",
|
|
175
|
+
hint=t("hint.machine_key_no_tokens"))
|
|
176
|
+
client = ctx.client()
|
|
177
|
+
if ctx.args.revoke:
|
|
178
|
+
if not ctx.confirm(t("prompt.revoke_token", token=ctx.args.revoke)):
|
|
179
|
+
raise CliError(t("err.not_revoked"), error="declined")
|
|
180
|
+
client.delete(f"/api/v1/oauth/tokens/{ctx.args.revoke}")
|
|
181
|
+
return {"ok": True, "revoked": ctx.args.revoke}
|
|
182
|
+
try:
|
|
183
|
+
return {"ok": True, **client.get("/api/v1/oauth/tokens")}
|
|
184
|
+
except Refused as e:
|
|
185
|
+
if e.error == "invalid_client":
|
|
186
|
+
raise CliError(t("err.no_tokens_to_list"), code=FORBIDDEN,
|
|
187
|
+
error="machine_key") from None
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _show_tokens(out: Out, body: dict[str, Any]) -> None:
|
|
192
|
+
if "revoked" in body:
|
|
193
|
+
out.done(t("tokens.revoked", token=body["revoked"]))
|
|
194
|
+
return
|
|
195
|
+
out.table(["token", "client", "scopes", "issued", "expires", "last used"],
|
|
196
|
+
[[t["token_id"], t.get("client_name") or t["client"], " ".join(t["scopes"]), t["issued_at"],
|
|
197
|
+
t["expires_at"], t["last_used_at"]] for t in body["tokens"]])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
COMMANDS = (
|
|
201
|
+
Command("login", "sign in through the browser with a device code",
|
|
202
|
+
_configure_login, login, _show_login, anonymous=True),
|
|
203
|
+
Command("logout", "revoke this machine's token and forget it",
|
|
204
|
+
_configure_logout, logout, _show_logout),
|
|
205
|
+
Command("tokens", "your delegated tokens, and revoke one",
|
|
206
|
+
_configure_tokens, tokens, _show_tokens),
|
|
207
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse models`: the model library, with its evidence scores.
|
|
4
|
+
|
|
5
|
+
lighthouse models
|
|
6
|
+
lighthouse models --provider bedrock
|
|
7
|
+
lighthouse models --json | jq '.items[] | select(.state == "available") | .model'
|
|
8
|
+
|
|
9
|
+
Reads `GET /api/v1/models`. Price cards are shown to a client administrator and
|
|
10
|
+
to AAL staff only, and the service decides that, not this command: a viewer's
|
|
11
|
+
output simply has no prices in it (`prices_visible` says which).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from lighthouse.main import Command, Context
|
|
19
|
+
from lighthouse.out import Out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
23
|
+
ap.add_argument("--task", help="accepted for compatibility; the library is not ranked "
|
|
24
|
+
"per task by the service, so it changes nothing")
|
|
25
|
+
ap.add_argument("--provider", help="only this provider's models")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def models(ctx: Context) -> dict[str, Any]:
|
|
29
|
+
body = ctx.client().get_all("/api/v1/models", "items")
|
|
30
|
+
rows = body.get("items") or []
|
|
31
|
+
# The route takes no filter, so this narrows the answer here.
|
|
32
|
+
if ctx.args.provider:
|
|
33
|
+
rows = [m for m in rows if m.get("provider") == ctx.args.provider]
|
|
34
|
+
if ctx.args.task:
|
|
35
|
+
ctx.out.warn("--task has no effect: the service does not rank the library per task")
|
|
36
|
+
return {"ok": True, **body, "items": rows}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
40
|
+
rows = body.get("items") or []
|
|
41
|
+
visible = bool(body.get("prices_visible"))
|
|
42
|
+
headers = ["model", "provider", "family", "state", "evals", "calls (month)"]
|
|
43
|
+
if visible:
|
|
44
|
+
headers += ["input /M", "output /M"]
|
|
45
|
+
table = []
|
|
46
|
+
for m in rows:
|
|
47
|
+
evals = m.get("evals") or []
|
|
48
|
+
row = [m.get("model"), m.get("provider"), m.get("family"), m.get("state"),
|
|
49
|
+
f"{sum(1 for e in evals if e.get('passed'))}/{len(evals)} passed",
|
|
50
|
+
m.get("calls_month")]
|
|
51
|
+
if visible:
|
|
52
|
+
price = m.get("price") or {}
|
|
53
|
+
row += [price.get("input_per_m"), price.get("output_per_m")]
|
|
54
|
+
table.append(row)
|
|
55
|
+
out.table(headers, table)
|
|
56
|
+
if visible:
|
|
57
|
+
out.note("prices are per million tokens, in the provider's own currency")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
COMMANDS = (Command("models", "the model library and its evidence scores",
|
|
61
|
+
_configure, models, _show),)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse runs`: what is running now, and what ran.
|
|
4
|
+
|
|
5
|
+
lighthouse runs # the last runs
|
|
6
|
+
lighthouse runs tail # live, one line per change
|
|
7
|
+
lighthouse runs tail --once --json # one snapshot, for a pipeline
|
|
8
|
+
lighthouse runs show <run_id_hash>
|
|
9
|
+
|
|
10
|
+
`tail` follows `GET /api/v1/runs/active` (contract 1.12): the live runs, each
|
|
11
|
+
with its current step, its tokens so far and its estimated cost. It polls at
|
|
12
|
+
`--interval` seconds rather than holding a server-sent-events stream, because
|
|
13
|
+
a poll survives a laptop lid, a corporate proxy and a CI runner's idle timeout,
|
|
14
|
+
and the read is cheap — the service answers it from the tenant's data version
|
|
15
|
+
(`store.query_cached`).
|
|
16
|
+
|
|
17
|
+
Under `--json`, `tail` writes **one JSON object per line** (JSON Lines) rather
|
|
18
|
+
than one document, so `lighthouse runs tail --json | jq -c .` streams. With
|
|
19
|
+
`--once` it writes a single document instead, which is the shape a pipeline
|
|
20
|
+
step wants. Both are covered by the golden test.
|
|
21
|
+
|
|
22
|
+
`--fail-on-stall` makes it exit 1 if a run has not been seen for
|
|
23
|
+
`stall_after_s`, which is the useful thing to put in a smoke job after a
|
|
24
|
+
deploy.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from lighthouse.exits import FAILED, CliError
|
|
34
|
+
from lighthouse.flags import inherited
|
|
35
|
+
from lighthouse.main import Command, Context
|
|
36
|
+
from lighthouse.out import Out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
40
|
+
sub = ap.add_subparsers(dest="runs_command", metavar="<subcommand>")
|
|
41
|
+
listing = sub.add_parser("list", parents=[inherited()], help="the last runs (the default)")
|
|
42
|
+
listing.add_argument("--limit", type=int, default=20)
|
|
43
|
+
listing.add_argument("--pack")
|
|
44
|
+
listing.add_argument("--status")
|
|
45
|
+
show = sub.add_parser("show", parents=[inherited()], help="one run")
|
|
46
|
+
show.add_argument("run", help="its run_id_hash")
|
|
47
|
+
show.add_argument("--map", action="store_true", dest="as_map",
|
|
48
|
+
help="the run map: every step, model call and retrieval")
|
|
49
|
+
tail = sub.add_parser("tail", parents=[inherited()], help="follow the live runs")
|
|
50
|
+
tail.add_argument("--interval", type=float, default=5.0, help="seconds between polls")
|
|
51
|
+
tail.add_argument("--once", action="store_true", help="one snapshot, then stop")
|
|
52
|
+
tail.add_argument("--for", type=float, dest="duration", default=None,
|
|
53
|
+
help="stop after this many seconds")
|
|
54
|
+
tail.add_argument("--deployment", help="only this deployment's runs")
|
|
55
|
+
tail.add_argument("--fail-on-stall", action="store_true", dest="fail_on_stall",
|
|
56
|
+
help="exit 1 if a run has stalled")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _line(run: dict[str, Any]) -> list[Any]:
|
|
60
|
+
step = (run.get("current_step") or {}).get("name")
|
|
61
|
+
tokens = run.get("tokens") or {}
|
|
62
|
+
return [run.get("run_id_hash"), run.get("agent"), run.get("state"), step,
|
|
63
|
+
f"{tokens.get('input', 0)}/{tokens.get('output', 0)}",
|
|
64
|
+
run.get("cost_usd"), f"{round((run.get('elapsed_ms') or 0) / 1000)}s"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _row(run: dict[str, Any]) -> list[Any]:
|
|
68
|
+
"""A finished run, as `GET /api/v1/runs` lists it: no tokens or cost, which
|
|
69
|
+
belong to the live view (`_line`) and to `runs show --map`."""
|
|
70
|
+
agent = f"{run.get('pack')}:{run.get('card')}" if run.get("card") else run.get("pack")
|
|
71
|
+
return [run.get("run_id_hash"), agent, run.get("started_at"), run.get("status"),
|
|
72
|
+
run.get("duration_ms"), run.get("attempts"), run.get("trigger")]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _stalled(body: dict[str, Any]) -> list[str]:
|
|
76
|
+
return [r["run_id_hash"] for r in (body.get("runs") or [])
|
|
77
|
+
if r.get("state") == "stalled"]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def tail(ctx: Context) -> dict[str, Any]:
|
|
81
|
+
client = ctx.client()
|
|
82
|
+
out = ctx.out
|
|
83
|
+
interval = max(0.5, float(ctx.args.interval))
|
|
84
|
+
sleep = ctx.sleep or time.sleep
|
|
85
|
+
clock = time.monotonic
|
|
86
|
+
# `--for 0` means one poll, so this tests `is not None` rather than truth.
|
|
87
|
+
duration = getattr(ctx.args, 'duration', None)
|
|
88
|
+
deadline = None if duration is None else clock() + duration
|
|
89
|
+
seen: dict[str, str] = {}
|
|
90
|
+
snapshots = 0
|
|
91
|
+
last: dict[str, Any] = {}
|
|
92
|
+
while True:
|
|
93
|
+
last = client.get("/api/v1/runs/active", deployment=ctx.args.deployment)
|
|
94
|
+
snapshots += 1
|
|
95
|
+
rows = last.get("runs") or []
|
|
96
|
+
if ctx.args.once:
|
|
97
|
+
break
|
|
98
|
+
for run in rows:
|
|
99
|
+
key = str(run.get("run_id_hash"))
|
|
100
|
+
fingerprint = json.dumps([run.get("state"),
|
|
101
|
+
(run.get("current_step") or {}).get("name"),
|
|
102
|
+
run.get("steps")], sort_keys=True)
|
|
103
|
+
if seen.get(key) == fingerprint:
|
|
104
|
+
continue # nothing changed: a tail should not repeat itself
|
|
105
|
+
seen[key] = fingerprint
|
|
106
|
+
if out.as_json:
|
|
107
|
+
out.json_line({"as_of": last.get("as_of"), **run})
|
|
108
|
+
else:
|
|
109
|
+
out.say(" ".join("" if c is None else str(c) for c in _line(run)))
|
|
110
|
+
if deadline is not None and clock() >= deadline:
|
|
111
|
+
break
|
|
112
|
+
sleep(interval)
|
|
113
|
+
stalled = _stalled(last)
|
|
114
|
+
if ctx.args.fail_on_stall and stalled:
|
|
115
|
+
raise CliError(f"{len(stalled)} run(s) have stalled", code=FAILED, error="stalled",
|
|
116
|
+
stalled=stalled)
|
|
117
|
+
return {"ok": True, "streamed": not ctx.args.once, "snapshots": snapshots,
|
|
118
|
+
"as_of": last.get("as_of"), "stall_after_s": last.get("stall_after_s"),
|
|
119
|
+
"live": last.get("live"), "runs": last.get("runs") or [], "stalled": stalled}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def runs(ctx: Context) -> dict[str, Any]:
|
|
123
|
+
which = getattr(ctx.args, "runs_command", None) or "list"
|
|
124
|
+
if which == "tail":
|
|
125
|
+
return tail(ctx)
|
|
126
|
+
client = ctx.client()
|
|
127
|
+
if which == "show":
|
|
128
|
+
path = f"/api/v1/runs/{ctx.args.run}"
|
|
129
|
+
if ctx.args.as_map:
|
|
130
|
+
path += "/map"
|
|
131
|
+
return {"ok": True, **client.get(path)}
|
|
132
|
+
rows = list(client.each("/api/v1/runs", "runs", limit=ctx.args.limit,
|
|
133
|
+
pack=ctx.args.pack, status=ctx.args.status))
|
|
134
|
+
return {"ok": True, "runs": rows}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
138
|
+
if body.get("streamed"):
|
|
139
|
+
return # `tail` printed as it went; nothing more to say
|
|
140
|
+
rows = body.get("runs")
|
|
141
|
+
if rows is not None and "snapshots" in body: # `tail`: the live view
|
|
142
|
+
out.table(["run", "agent", "state", "step", "tokens in/out", "estimated cost", "elapsed"],
|
|
143
|
+
[_line(r) for r in rows])
|
|
144
|
+
if body.get("stalled"):
|
|
145
|
+
out.warn(f"stalled: {', '.join(body['stalled'])}")
|
|
146
|
+
out.note("every cost figure is estimated")
|
|
147
|
+
return
|
|
148
|
+
if rows is not None:
|
|
149
|
+
out.table(["run", "agent", "started", "status", "took (ms)", "attempts", "trigger"],
|
|
150
|
+
[_row(r) for r in rows])
|
|
151
|
+
return
|
|
152
|
+
out.pairs([(k, v) for k, v in body.items()
|
|
153
|
+
if k != "ok" and not isinstance(v, (list, dict))])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
COMMANDS = (Command("runs", "what is running now, and what ran",
|
|
157
|
+
_configure, runs, _show),)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Copyright 2026 Atlantic Agentic Labs. All Rights Reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""`lighthouse spend`: what was spent, what it is heading for, what was saved.
|
|
4
|
+
|
|
5
|
+
lighthouse spend # this month, by day
|
|
6
|
+
lighthouse spend --by model
|
|
7
|
+
lighthouse spend --month 2026-10 --json
|
|
8
|
+
lighthouse spend --forecast
|
|
9
|
+
lighthouse spend --savings
|
|
10
|
+
|
|
11
|
+
Reads `GET /api/v1/billing/usage` (contract 1.6) and, with `--forecast`, the
|
|
12
|
+
month-end projection, and with `--savings` the estimated headline. Figures come
|
|
13
|
+
back in the provider's own currency with the caller's display currency beside
|
|
14
|
+
them, as the API returns them; this command converts nothing itself, because a
|
|
15
|
+
CLI quietly applying its own exchange rate is how two reports of the same month
|
|
16
|
+
come to disagree.
|
|
17
|
+
|
|
18
|
+
**Every saving figure says "estimated"**, here as everywhere.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from lighthouse.main import Command, Context
|
|
26
|
+
from lighthouse.out import Out
|
|
27
|
+
|
|
28
|
+
GROUPS = ("day", "model", "pack", "agent", "connection")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _configure(ap: argparse.ArgumentParser) -> None:
|
|
32
|
+
ap.add_argument("--by", choices=GROUPS, default="day", help="how to group")
|
|
33
|
+
ap.add_argument("--month", help="YYYY-MM; the current month by default")
|
|
34
|
+
ap.add_argument("--forecast", action="store_true",
|
|
35
|
+
help="the month-end projection against the cap as well")
|
|
36
|
+
ap.add_argument("--savings", action="store_true",
|
|
37
|
+
help="the estimated savings headline as well")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def spend(ctx: Context) -> dict[str, Any]:
|
|
41
|
+
client = ctx.client()
|
|
42
|
+
out: dict[str, Any] = {"ok": True, "group_by": ctx.args.by,
|
|
43
|
+
**client.get("/api/v1/billing/usage",
|
|
44
|
+
month=ctx.args.month, group_by=ctx.args.by)}
|
|
45
|
+
if ctx.args.forecast:
|
|
46
|
+
out["forecast"] = client.get("/api/v1/forecast")
|
|
47
|
+
if ctx.args.savings:
|
|
48
|
+
out["savings"] = client.get("/api/v1/savings/headline", period=ctx.args.month)
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _show(out: Out, body: dict[str, Any]) -> None:
|
|
53
|
+
rows = body.get("rows") or []
|
|
54
|
+
out.table([body["group_by"], "calls", "input tokens", "output tokens", "cost (USD)",
|
|
55
|
+
"managed charge"],
|
|
56
|
+
[[r.get("key"), r.get("calls"), r.get("input_tokens"), r.get("output_tokens"),
|
|
57
|
+
r.get("cost_usd"), r.get("charge_usd")] for r in rows])
|
|
58
|
+
total = body.get("total") or {}
|
|
59
|
+
if total:
|
|
60
|
+
out.say("")
|
|
61
|
+
out.pairs([("month", body.get("month")),
|
|
62
|
+
("total cost (USD)", total.get("cost_usd")),
|
|
63
|
+
("managed charge", total.get("charge_usd")),
|
|
64
|
+
("shown in", (body.get("display") or {}).get("currency"))])
|
|
65
|
+
forecast = body.get("forecast")
|
|
66
|
+
if forecast:
|
|
67
|
+
projection = forecast.get("projection") or {}
|
|
68
|
+
out.say("")
|
|
69
|
+
out.pairs([("projected month end (estimated)", projection.get("month_end_usd")),
|
|
70
|
+
("likely range (estimated)",
|
|
71
|
+
f"{projection['low_usd']} to {projection['high_usd']}" if projection else None),
|
|
72
|
+
("against the cap", forecast.get("cap_usd")),
|
|
73
|
+
("status", forecast.get("status"))])
|
|
74
|
+
savings = body.get("savings")
|
|
75
|
+
if savings:
|
|
76
|
+
headline = savings.get("headline") or {}
|
|
77
|
+
out.say("")
|
|
78
|
+
out.pairs([("estimated saving this month", headline.get("usd")),
|
|
79
|
+
("in words", headline.get("sentence"))])
|
|
80
|
+
out.note("every saving and forecast figure is estimated")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
COMMANDS = (Command("spend", "what was spent, what it is heading for, what was saved",
|
|
84
|
+
_configure, spend, _show),)
|