tare-cli 0.2.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.
tare/trail.py ADDED
@@ -0,0 +1,203 @@
1
+ """Trail: one neutral record of a run, the same for every agent (epic Swap).
2
+
3
+ A trail is the task, the agent's messages, its tool calls and their results. Each adapter
4
+ translates its own session format into it; one renderer turns a trail prefix into a
5
+ handoff prompt that any agent can continue from. A new agent needs a snapshot hook, a
6
+ translation into the trail and a way to start a session with a prompt; capsules, tails,
7
+ Cliff and Swap come with it.
8
+ """
9
+ import json
10
+ from dataclasses import dataclass, field
11
+
12
+ SHORT = 600 # characters kept of a tool's input or output in a handoff
13
+
14
+
15
+ @dataclass
16
+ class Event:
17
+ kind: str # say, call, result
18
+ text: str
19
+ tool: str = ""
20
+
21
+
22
+ @dataclass
23
+ class Step:
24
+ """One model tool call (or several issued together) and where it ends."""
25
+ ids: list[str] # the ids the snapshot hook saw for this step, in order
26
+ trail_cut: int # events of the trail up to and including this step's results
27
+ native_cut: int | None # lines of the native session up to this step (native resume), if any
28
+ tool: str # what the step did, for reports
29
+
30
+
31
+ @dataclass
32
+ class Trail:
33
+ events: list[Event] = field(default_factory=list)
34
+ steps: list[Step] = field(default_factory=list)
35
+
36
+
37
+ def _short(text: str) -> str:
38
+ text = text.strip()
39
+ return text if len(text) <= SHORT else text[:SHORT] + f" … [{len(text) - SHORT} more characters]"
40
+
41
+
42
+ def _text(content) -> str:
43
+ if isinstance(content, str):
44
+ return content
45
+ if isinstance(content, list):
46
+ return "\n".join(c.get("text", "") for c in content if isinstance(c, dict))
47
+ return "" if content is None else json.dumps(content)
48
+
49
+
50
+ def claude(lines: list[str]) -> Trail:
51
+ """Claude Code session transcript (JSON lines) to a trail."""
52
+ trail = Trail()
53
+ calls: dict[int, list[str]] = {} # transcript line of an assistant message -> its tool ids
54
+ origin: dict[str, int] = {}
55
+ described: dict[str, str] = {}
56
+ waiting: dict[int, set[str]] = {}
57
+ for i, line in enumerate(lines):
58
+ message = json.loads(line).get("message") or {}
59
+ content = message.get("content")
60
+ if not isinstance(content, list):
61
+ continue
62
+ for block in content:
63
+ kind = block.get("type")
64
+ if kind == "text" and message.get("role") == "assistant" and block.get("text", "").strip():
65
+ trail.events.append(Event("say", block["text"].strip()))
66
+ elif kind == "tool_use":
67
+ arguments = block.get("input", {})
68
+ detail = str(arguments.get("command") or arguments.get("file_path") or json.dumps(arguments))
69
+ trail.events.append(Event("call", _short(detail), block.get("name", "?")))
70
+ calls.setdefault(i, []).append(block["id"])
71
+ origin[block["id"]] = i
72
+ described[block["id"]] = f"{block.get('name', '?')} {detail.splitlines()[0][:70] if detail else ''}".strip()
73
+ waiting.setdefault(i, set()).add(block["id"])
74
+ elif kind == "tool_result":
75
+ trail.events.append(Event("result", _short(_text(block.get("content")))))
76
+ made = origin.get(block.get("tool_use_id"))
77
+ if made is None:
78
+ continue
79
+ waiting[made].discard(block["tool_use_id"])
80
+ if not waiting[made]: # every call of that message has its result: the step ends here
81
+ ids = calls[made]
82
+ trail.steps.append(Step(ids, len(trail.events), i + 1, " + ".join(described[t] for t in ids)))
83
+ return trail
84
+
85
+
86
+ def codex(lines: list[str]) -> Trail:
87
+ """Codex rollout (JSON lines) to a trail. In code mode one model call (`exec`) runs several
88
+ commands; the snapshot hook sees the commands, so a step carries their ids."""
89
+ trail = Trail()
90
+ calls: dict[str, dict] = {} # call_id -> {"inner": [...], "tool": ...}
91
+ current: str | None = None
92
+ for i, line in enumerate(lines):
93
+ entry = json.loads(line)
94
+ payload = entry.get("payload") or {}
95
+ kind = entry.get("type")
96
+ if kind == "response_item":
97
+ item = payload.get("type")
98
+ if item == "message" and payload.get("role") == "assistant":
99
+ text = _text(payload.get("content")).strip()
100
+ if text:
101
+ trail.events.append(Event("say", text))
102
+ elif item in ("custom_tool_call", "function_call"):
103
+ detail = payload.get("input") or payload.get("arguments") or ""
104
+ name = payload.get("name", "?")
105
+ trail.events.append(Event("call", _short(str(detail)), name))
106
+ current = payload["call_id"]
107
+ first = str(detail).strip().splitlines()[0][:70] if str(detail).strip() else ""
108
+ calls[current] = {"inner": [current], "tool": f"{name} {first}".strip()}
109
+ elif item in ("custom_tool_call_output", "function_call_output"):
110
+ trail.events.append(Event("result", _short(_text(payload.get("output")))))
111
+ call = calls.get(payload.get("call_id"))
112
+ if call:
113
+ trail.steps.append(Step(call["inner"], len(trail.events), i + 1, call["tool"]))
114
+ current = None
115
+ elif kind == "event_msg" and payload.get("type") == "item_completed" and current:
116
+ item = payload.get("item") or {}
117
+ if item.get("type") == "CommandExecution" and item.get("id"):
118
+ calls[current]["inner"].append(item["id"])
119
+ return trail
120
+
121
+
122
+ def pi(lines: list[str]) -> Trail:
123
+ """Pi session (JSON lines, a tree of message entries) to a trail."""
124
+ trail = Trail()
125
+ calls: dict[int, list[str]] = {}
126
+ origin: dict[str, int] = {}
127
+ described: dict[str, str] = {}
128
+ waiting: dict[int, set[str]] = {}
129
+ for i, line in enumerate(lines):
130
+ entry = json.loads(line)
131
+ if entry.get("type") != "message":
132
+ continue
133
+ message = entry.get("message") or {}
134
+ role = message.get("role")
135
+ if role == "assistant":
136
+ for block in message.get("content") or []:
137
+ if block.get("type") == "text" and block.get("text", "").strip():
138
+ trail.events.append(Event("say", block["text"].strip()))
139
+ elif block.get("type") == "toolCall":
140
+ arguments = block.get("arguments") or {}
141
+ detail = str(arguments.get("command") or arguments.get("path") or json.dumps(arguments))
142
+ trail.events.append(Event("call", _short(detail), block.get("name", "?")))
143
+ calls.setdefault(i, []).append(block["id"])
144
+ origin[block["id"]] = i
145
+ described[block["id"]] = f"{block.get('name', '?')} {detail.splitlines()[0][:70] if detail else ''}".strip()
146
+ waiting.setdefault(i, set()).add(block["id"])
147
+ elif role == "toolResult":
148
+ trail.events.append(Event("result", _short(_text(message.get("content")))))
149
+ made = origin.get(message.get("toolCallId"))
150
+ if made is None:
151
+ continue
152
+ waiting[made].discard(message["toolCallId"])
153
+ if not waiting[made]:
154
+ ids = calls[made]
155
+ trail.steps.append(Step(ids, len(trail.events), i + 1, " + ".join(described[t] for t in ids)))
156
+ return trail
157
+
158
+
159
+ def agy(lines: list[str]) -> Trail:
160
+ """Antigravity CLI transcript (transcript_full.jsonl) to a trail. A planner response carries the
161
+ tool calls; each result is a step entry of its own, whose step_index the snapshot hook sees."""
162
+ trail = Trail()
163
+ waiting: list[str] = [] # the calls of the last planner response without a result yet
164
+ ids: list[str] = []
165
+ done: list[str] = []
166
+ for line in lines:
167
+ entry = json.loads(line)
168
+ kind = entry.get("type")
169
+ if kind == "PLANNER_RESPONSE":
170
+ if (entry.get("content") or "").strip():
171
+ trail.events.append(Event("say", entry["content"].strip()))
172
+ for call in entry.get("tool_calls") or []:
173
+ arguments = call.get("args") or {}
174
+ detail = str(arguments.get("CommandLine") or arguments.get("TargetFile") or arguments.get("AbsolutePath")
175
+ or json.dumps(arguments))
176
+ trail.events.append(Event("call", _short(detail), call.get("name", "?")))
177
+ waiting.append(f"{call.get('name', '?')} {detail.splitlines()[0][:70] if detail else ''}".strip())
178
+ elif waiting and kind != "USER_INPUT":
179
+ trail.events.append(Event("result", _short(_text(entry.get("content")))))
180
+ ids.append(f"step-{entry.get('step_index')}")
181
+ done.append(waiting.pop(0))
182
+ if not waiting: # every call of the planner response has its result: the step ends here
183
+ trail.steps.append(Step(ids, len(trail.events), None, " + ".join(done)))
184
+ ids, done = [], []
185
+ return trail
186
+
187
+
188
+ def render(task: str, events: list[Event], workspace_only: bool = False) -> str:
189
+ """The handoff prompt: the task and what happened so far, the same for every agent."""
190
+ head = ("You are taking over a task in /work. The workspace is exactly as the previous session "
191
+ "left it.\n\nThe task:\n" + task.strip() + "\n")
192
+ if workspace_only or not events:
193
+ return head + "\nContinue from the current state of the workspace and finish the task."
194
+ lines = []
195
+ for n, event in enumerate(events, 1):
196
+ if event.kind == "say":
197
+ lines.append(f"{n}. The agent said: {event.text}")
198
+ elif event.kind == "call":
199
+ lines.append(f"{n}. The agent called {event.tool}: {event.text}")
200
+ else:
201
+ lines.append(f"{n}. Result: {event.text}")
202
+ return (head + "\nWhat happened so far (long inputs and outputs are shortened):\n" + "\n".join(lines)
203
+ + "\n\nContinue from here and finish the task.")
tare/usage.py ADDED
@@ -0,0 +1,237 @@
1
+ """Usage: the tokens and the cost of agent runs (epic Token and cost tracking).
2
+
3
+ Each adapter reads its agent's own stream after a run into one shape. `input` counts every
4
+ input token, cached ones included, and `cached` is the part read from a prompt cache. `cost_usd`
5
+ is what the agent itself reports, or None when it reports none. Sums keep count of the runs
6
+ whose cost is unknown, so a total never looks complete when it is not.
7
+
8
+ Where an agent reports no cost, tare estimates it from a price table: LiteLLM's public table,
9
+ fetched on demand with `tare prices update`, and a file of the user's own that overrides single
10
+ models. An estimate always names its source and date.
11
+ """
12
+ import json
13
+ import os
14
+ import re
15
+ import time
16
+ import urllib.request
17
+ from dataclasses import asdict, dataclass
18
+ from pathlib import Path
19
+
20
+ LITELLM_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
21
+ # where a model is listed under several prefixes, the maker's own price comes before resellers'
22
+ FIRST_PARTY = ("openai", "anthropic", "gemini", "zai", "xai", "mistral", "deepseek", "moonshot", "dashscope")
23
+
24
+
25
+ @dataclass
26
+ class Usage:
27
+ input: int = 0
28
+ cached: int = 0
29
+ cache_write: int = 0
30
+ output: int = 0
31
+ reasoning: int = 0
32
+ cost_usd: float | None = None
33
+ model: str | None = None
34
+ runs: int = 1
35
+ unpriced: int = 0 # runs among `runs` whose cost is unknown
36
+ estimated: str | None = None # the price table behind an estimated cost, None for a reported one
37
+
38
+ def __post_init__(self):
39
+ if self.runs == 1 and self.cost_usd is None and self.unpriced == 0:
40
+ self.unpriced = 1
41
+
42
+ def __add__(self, other: "Usage") -> "Usage":
43
+ costs = [u.cost_usd for u in (self, other) if u.cost_usd is not None]
44
+ return Usage(self.input + other.input, self.cached + other.cached, self.cache_write + other.cache_write,
45
+ self.output + other.output, self.reasoning + other.reasoning,
46
+ sum(costs) if costs else None, _same(self.model, other.model),
47
+ self.runs + other.runs, self.unpriced + other.unpriced,
48
+ _same(self.estimated, other.estimated) if self.estimated and other.estimated
49
+ else self.estimated or other.estimated)
50
+
51
+ def to_dict(self) -> dict:
52
+ return asdict(self)
53
+
54
+ @classmethod
55
+ def from_dict(cls, data: dict | None) -> "Usage | None":
56
+ if not data:
57
+ return None
58
+ return cls(**{k: data[k] for k in cls.__dataclass_fields__ if k in data})
59
+
60
+
61
+ def _same(a: str | None, b: str | None) -> str | None:
62
+ """The model of a sum: the one model if all parts name the same (or none), else None."""
63
+ return a if b in (None, a) else b if a is None else None
64
+
65
+
66
+ def billing(agent, real) -> str | None:
67
+ """How a side's login pays ("subscription" or "api key"), where the adapter can tell."""
68
+ try:
69
+ return agent.billing(real) if real is not None and hasattr(agent, "billing") else None
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def total(usages) -> "Usage | None":
75
+ """The sum of several usages (dicts or Usage), or None when there are none."""
76
+ result = None
77
+ for u in usages:
78
+ u = Usage.from_dict(u) if isinstance(u, dict) else u
79
+ if u is not None:
80
+ result = u if result is None else result + u
81
+ return result
82
+
83
+
84
+ def _count(n: int) -> str:
85
+ return f"{n / 1e6:.2f}M" if n >= 1e6 else f"{n / 1e3:.1f}k" if n >= 1e3 else str(n)
86
+
87
+
88
+ def describe(u: "Usage | None") -> str:
89
+ """One line: runs, tokens in and out, and the cost as far as it is known."""
90
+ if u is None:
91
+ return "no usage recorded"
92
+ runs = f"{u.runs} run{'s' if u.runs != 1 else ''}, "
93
+ tokens = f"{_count(u.input)} in ({_count(u.cached)} cached), {_count(u.output)} out"
94
+ if u.cost_usd is None:
95
+ cost = "cost unknown"
96
+ elif u.estimated:
97
+ cost = f"about {money(u.cost_usd)} (estimated from {u.estimated})"
98
+ elif u.unpriced:
99
+ priced = u.runs - u.unpriced
100
+ cost = f"{money(u.cost_usd)} for {priced} run{'s' if priced != 1 else ''}, cost unknown for {u.unpriced}"
101
+ else:
102
+ cost = money(u.cost_usd)
103
+ return f"{runs}{tokens}, {cost}"
104
+
105
+
106
+ def money(usd: float) -> str:
107
+ return f"${usd:.3f}" if usd < 10 else f"${usd:.2f}"
108
+
109
+
110
+ def of_run(events: list[dict]) -> dict:
111
+ """The usage of a run, from its journal: per side (original and tails), the check, and the total."""
112
+ labels: dict[str, str] = {}
113
+ info: dict[str, dict] = {}
114
+ owner: dict[str, str] = {} # tail id -> side key, from the event that started it
115
+ sides: dict[str, list] = {}
116
+ check: list = []
117
+ for e in events:
118
+ kind = e.get("event")
119
+ if kind == "start":
120
+ info = e.get("sides") or {}
121
+ labels = {k: " ".join([v.get("agent", "?"), *v.get("args", [])]) for k, v in info.items()}
122
+ elif kind == "tail" and e.get("status") == "running":
123
+ owner[e["id"]] = e.get("agent") or e.get("side") or "?"
124
+ elif kind in ("original", "tail"):
125
+ key = e.get("side") if kind == "original" else owner.get(e.get("id"), "?")
126
+ sides.setdefault(key, []).append(e.get("usage"))
127
+ check.append(e.get("check_usage"))
128
+ per_side = {}
129
+ for k, v in sides.items():
130
+ side = info.get(k) or {}
131
+ found = total(v)
132
+ per_side[k] = found and estimate(found, model_from_args(side.get("args") or []),
133
+ reasoning_extra=side.get("agent") == "agy")
134
+ checks = total(check)
135
+ everything = total([*per_side.values(), checks])
136
+ return {"sides": {k: {"label": labels.get(k, k), "usage": u.to_dict() if u else None,
137
+ "billing": (info.get(k) or {}).get("billing")} for k, u in per_side.items()},
138
+ "check": checks.to_dict() if checks else None, "total": everything.to_dict() if everything else None}
139
+
140
+
141
+ def report(out) -> list[str]:
142
+ """The usage lines that end a report. Also writes usage.json into the run directory."""
143
+ from . import journal as journals
144
+ summary = of_run(journals.read(out))
145
+ (out / "usage.json").write_text(json.dumps(summary, indent=2) + "\n")
146
+ if summary["total"] is None:
147
+ return []
148
+ rows = [f"{k} {s['label']}: {describe(Usage.from_dict(s['usage']))}"
149
+ + (". Subscription login, so the cost is notional" if s.get("billing") == "subscription" else "")
150
+ for k, s in sorted(summary["sides"].items()) if s["usage"]]
151
+ if summary["check"]:
152
+ rows.append(f"check (the judge): {describe(Usage.from_dict(summary['check']))}")
153
+ rows.append(f"total: {describe(Usage.from_dict(summary['total']))}")
154
+ return ["", f" usage {rows[0]}", *[f" {r}" for r in rows[1:]]]
155
+
156
+
157
+ # --- prices -----------------------------------------------------------------------------------
158
+
159
+ def config_dir() -> Path:
160
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "tare"
161
+
162
+
163
+ def update_prices(url: str = LITELLM_URL) -> str:
164
+ """Fetch LiteLLM's public price table into the config directory. Returns a one-line summary."""
165
+ with urllib.request.urlopen(url, timeout=60) as r:
166
+ models = json.loads(r.read())
167
+ fetched = time.strftime("%Y-%m-%d")
168
+ config_dir().mkdir(parents=True, exist_ok=True)
169
+ (config_dir() / "prices-litellm.json").write_text(json.dumps({"source": url, "fetched": fetched, "models": models}))
170
+ return f"{len(models)} models from LiteLLM, fetched {fetched}"
171
+
172
+
173
+ def _tables() -> list[tuple[str, dict]]:
174
+ """(label, {model: per-token prices}) for the user's own file, then LiteLLM's table."""
175
+ tables = []
176
+ own = config_dir() / "prices.json"
177
+ if own.exists():
178
+ data = json.loads(own.read_text())
179
+ per_million = {m: {k: v / 1e6 for k, v in p.items()} for m, p in (data.get("models") or {}).items()}
180
+ tables.append((f"{own.name} ({data.get('source', 'no source given')}, {data.get('date', 'no date given')})",
181
+ per_million))
182
+ litellm = config_dir() / "prices-litellm.json"
183
+ if litellm.exists():
184
+ data = json.loads(litellm.read_text())
185
+ models = {m: {"input": p.get("input_cost_per_token"), "cached_input": p.get("cache_read_input_token_cost"),
186
+ "cache_write": p.get("cache_creation_input_token_cost"), "output": p.get("output_cost_per_token")}
187
+ for m, p in (data.get("models") or {}).items() if isinstance(p, dict) and p.get("input_cost_per_token")}
188
+ tables.append((f"LiteLLM prices fetched {data.get('fetched')}", models))
189
+ return tables
190
+
191
+
192
+ def tables_summary() -> list[str]:
193
+ return [f"{label}: {len(table)} models" for label, table in _tables()]
194
+
195
+
196
+ def price_of(model: str | None) -> tuple[dict, str] | None:
197
+ """The per-token prices of a model and the table they come from, or None. Tries the name as it
198
+ is, then without an effort suffix (-high, -low ...), then under a provider prefix (zai/glm-5.3)."""
199
+ if not model:
200
+ return None
201
+ names = [model, re.sub(r"-(minimal|low|medium|high|xhigh|max)$", "", model)]
202
+ for label, table in _tables():
203
+ for name in names:
204
+ if name in table:
205
+ return table[name], label
206
+ prefixed = sorted((k for k in table if k.endswith("/" + name) and k.count("/") == 1),
207
+ key=lambda k: (FIRST_PARTY.index(k.split("/")[0]) if k.split("/")[0] in FIRST_PARTY
208
+ else len(FIRST_PARTY), k))
209
+ if prefixed:
210
+ return table[prefixed[0]], label
211
+ return None
212
+
213
+
214
+ def estimate(u: Usage, model: str | None, reasoning_extra: bool = False) -> Usage:
215
+ """Fill in the cost of a usage whose runs all lack one, from the price table. `reasoning_extra`
216
+ is for agents whose output count leaves out the reasoning tokens that are billed as output."""
217
+ if u.cost_usd is not None or u.unpriced != u.runs:
218
+ return u
219
+ found = price_of(model or u.model)
220
+ if not found:
221
+ return u
222
+ p, label = found
223
+ fresh = max(0, u.input - u.cached - u.cache_write)
224
+ output = u.output + (u.reasoning if reasoning_extra else 0)
225
+ cost = (fresh * (p.get("input") or 0) + u.cached * (p.get("cached_input") or p.get("input") or 0)
226
+ + u.cache_write * (p.get("cache_write") or p.get("input") or 0) + output * (p.get("output") or 0))
227
+ return Usage(**{**u.to_dict(), "cost_usd": cost, "unpriced": 0, "estimated": label})
228
+
229
+
230
+ def model_from_args(args: list[str]) -> str | None:
231
+ """The model a side pins with -m or --model, if any."""
232
+ for i, arg in enumerate(args):
233
+ if arg in ("-m", "--model") and i + 1 < len(args):
234
+ return args[i + 1]
235
+ if arg.startswith("--model="):
236
+ return arg.split("=", 1)[1]
237
+ return None