kunji 0.1.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.
- kunji/__init__.py +3 -0
- kunji/__main__.py +4 -0
- kunji/catalog.py +174 -0
- kunji/chat.py +337 -0
- kunji/checkin.py +217 -0
- kunji/cli.py +763 -0
- kunji/compare.py +131 -0
- kunji/config.py +181 -0
- kunji/constants.py +22 -0
- kunji/engine.py +90 -0
- kunji/keystore.py +108 -0
- kunji/providers/__init__.py +26 -0
- kunji/providers/anthropic.py +96 -0
- kunji/providers/base.py +188 -0
- kunji/providers/openai_compat.py +110 -0
- kunji/providers/registry.py +154 -0
- kunji/ui/__init__.py +47 -0
- kunji/ui/components.py +438 -0
- kunji/ui/input.py +153 -0
- kunji/ui/theme.py +70 -0
- kunji/usage.py +124 -0
- kunji-0.1.0.dist-info/METADATA +207 -0
- kunji-0.1.0.dist-info/RECORD +26 -0
- kunji-0.1.0.dist-info/WHEEL +4 -0
- kunji-0.1.0.dist-info/entry_points.txt +2 -0
- kunji-0.1.0.dist-info/licenses/LICENSE +201 -0
kunji/__init__.py
ADDED
kunji/__main__.py
ADDED
kunji/catalog.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Model prices for the live cost meter.
|
|
2
|
+
|
|
3
|
+
Price lookup order (first match wins):
|
|
4
|
+
1. Your own overrides in config: [pricing."provider:model"] input = 0.15, output = 0.6
|
|
5
|
+
2. Kunji's curated catalog (catalog/pricing.json in the repo, fetched + cached 24h)
|
|
6
|
+
3. OpenRouter's public model list (fetched + cached 24h)
|
|
7
|
+
|
|
8
|
+
All prices are USD per 1 million tokens. Fetching sends no user data.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from kunji import __version__
|
|
24
|
+
from kunji.config import Config, atomic_write, cache_dir
|
|
25
|
+
from kunji.constants import CACHE_TTL_SECONDS, CATALOG_URL, OPENROUTER_MODELS_URL
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Price:
|
|
30
|
+
input: float # USD per 1M input tokens
|
|
31
|
+
output: float # USD per 1M output tokens
|
|
32
|
+
source: str
|
|
33
|
+
|
|
34
|
+
def cost(self, input_tokens: int, output_tokens: int) -> float:
|
|
35
|
+
return (input_tokens * self.input + output_tokens * self.output) / 1_000_000
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_DATE_SUFFIX = re.compile(r"(-\d{8}|-\d{4}-\d{2}-\d{2}|-latest|-preview)$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize(model_id: str) -> str:
|
|
42
|
+
value = model_id.lower().strip()
|
|
43
|
+
value = re.sub(r"[._:\s]+", "-", value)
|
|
44
|
+
prev = None
|
|
45
|
+
while prev != value:
|
|
46
|
+
prev = value
|
|
47
|
+
value = _DATE_SUFFIX.sub("", value)
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cache_file(name: str) -> Path:
|
|
52
|
+
return cache_dir() / name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_cache(name: str) -> dict[str, Any] | None:
|
|
56
|
+
path = _cache_file(name)
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
59
|
+
except (OSError, json.JSONDecodeError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fresh(blob: dict[str, Any] | None) -> bool:
|
|
64
|
+
return bool(blob) and time.time() - float(blob.get("fetched_at", 0)) < CACHE_TTL_SECONDS
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fetch_json(url: str, timeout: float = 6.0) -> Any:
|
|
68
|
+
with httpx.Client(timeout=timeout, headers={"user-agent": f"kunji/{__version__}"}) as client:
|
|
69
|
+
response = client.get(url)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return response.json()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _parse_openrouter(data: Any) -> dict[str, list[float]]:
|
|
75
|
+
out: dict[str, list[float]] = {}
|
|
76
|
+
for item in (data or {}).get("data", []):
|
|
77
|
+
try:
|
|
78
|
+
pricing = item.get("pricing") or {}
|
|
79
|
+
prompt = float(pricing.get("prompt", 0)) * 1_000_000
|
|
80
|
+
completion = float(pricing.get("completion", 0)) * 1_000_000
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
continue
|
|
83
|
+
if prompt < 0 or completion < 0:
|
|
84
|
+
continue
|
|
85
|
+
out[str(item.get("id"))] = [round(prompt, 6), round(completion, 6)]
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def refresh(cfg: Config, force: bool = False) -> None:
|
|
90
|
+
"""Refresh cached catalogs. Never raises — prices are a nice-to-have."""
|
|
91
|
+
if not cfg.get("network.catalog", True):
|
|
92
|
+
return
|
|
93
|
+
for name, url, parser in (
|
|
94
|
+
("openrouter_prices.json", OPENROUTER_MODELS_URL, _parse_openrouter),
|
|
95
|
+
("kunji_prices.json", CATALOG_URL, lambda d: (d or {}).get("models", {})),
|
|
96
|
+
):
|
|
97
|
+
if not force and _fresh(_read_cache(name)):
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
prices = parser(_fetch_json(url))
|
|
101
|
+
except Exception: # noqa: BLE001, S112 - offline or blocked is fine
|
|
102
|
+
continue
|
|
103
|
+
blob = {"fetched_at": time.time(), "prices": prices}
|
|
104
|
+
try:
|
|
105
|
+
atomic_write(_cache_file(name), json.dumps(blob).encode("utf-8"))
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def refresh_in_background(cfg: Config) -> threading.Thread:
|
|
111
|
+
thread = threading.Thread(target=refresh, args=(cfg,), daemon=True)
|
|
112
|
+
thread.start()
|
|
113
|
+
return thread
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Catalog:
|
|
117
|
+
def __init__(self, cfg: Config):
|
|
118
|
+
self.cfg = cfg
|
|
119
|
+
self._kunji = (_read_cache("kunji_prices.json") or {}).get("prices", {})
|
|
120
|
+
self._openrouter = (_read_cache("openrouter_prices.json") or {}).get("prices", {})
|
|
121
|
+
self._or_norm: dict[str, list[float]] = {}
|
|
122
|
+
for key, value in self._openrouter.items():
|
|
123
|
+
self._or_norm.setdefault(normalize(key), value)
|
|
124
|
+
|
|
125
|
+
def price_for(self, provider_id: str, model: str, prefix: str | None = None) -> Price | None:
|
|
126
|
+
ref = f"{provider_id}:{model}"
|
|
127
|
+
user = (self.cfg.get("pricing") or {}).get(ref)
|
|
128
|
+
if isinstance(user, dict):
|
|
129
|
+
try:
|
|
130
|
+
return Price(float(user["input"]), float(user["output"]), "your config")
|
|
131
|
+
except (KeyError, TypeError, ValueError):
|
|
132
|
+
pass
|
|
133
|
+
curated = self._kunji.get(ref)
|
|
134
|
+
if isinstance(curated, list | tuple) and len(curated) == 2:
|
|
135
|
+
return Price(float(curated[0]), float(curated[1]), "kunji catalog")
|
|
136
|
+
|
|
137
|
+
if prefix is None:
|
|
138
|
+
return None
|
|
139
|
+
or_id = model if prefix == "" else f"{prefix}/{model}"
|
|
140
|
+
hit = self._openrouter.get(or_id) or self._or_norm.get(normalize(or_id))
|
|
141
|
+
if hit:
|
|
142
|
+
return Price(float(hit[0]), float(hit[1]), "openrouter")
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def cost(self, provider_id, model, prefix, input_tokens, output_tokens) -> float | None:
|
|
146
|
+
price = self.price_for(provider_id, model, prefix)
|
|
147
|
+
return None if price is None else price.cost(input_tokens, output_tokens)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# Money formatting ---------------------------------------------------------------
|
|
151
|
+
_SYMBOLS = {"USD": "$", "INR": "₹", "EUR": "€", "GBP": "£", "JPY": "¥"}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def to_display(cfg: Config, usd: float) -> float:
|
|
155
|
+
try:
|
|
156
|
+
rate = float(cfg.get("display.usd_rate", 1.0))
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
rate = 1.0
|
|
159
|
+
return usd * (rate if rate > 0 else 1.0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def fmt_money(cfg: Config, usd: float | None, *, display_amount: float | None = None) -> str:
|
|
163
|
+
if usd is None and display_amount is None:
|
|
164
|
+
return "—"
|
|
165
|
+
currency = str(cfg.get("display.currency", "USD")).upper()
|
|
166
|
+
symbol = _SYMBOLS.get(currency, currency + " ")
|
|
167
|
+
amount = display_amount if display_amount is not None else to_display(cfg, usd or 0.0)
|
|
168
|
+
if amount == 0:
|
|
169
|
+
return f"{symbol}0"
|
|
170
|
+
if amount < 0.0001:
|
|
171
|
+
return f"<{symbol}0.0001"
|
|
172
|
+
if amount < 0.01:
|
|
173
|
+
return f"{symbol}{amount:.4f}"
|
|
174
|
+
return f"{symbol}{amount:,.2f}"
|
kunji/chat.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Interactive chat session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.live import Live
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from kunji import checkin, usage
|
|
14
|
+
from kunji.catalog import Catalog, fmt_money, refresh_in_background
|
|
15
|
+
from kunji.config import Config, cache_dir, data_dir, ensure_dir
|
|
16
|
+
from kunji.engine import TurnResult, fallback_chain, stream_turn
|
|
17
|
+
from kunji.providers import Message, ProviderError, ProviderSpec, make_provider, resolve_ref
|
|
18
|
+
from kunji.providers.registry import all_specs, key_for
|
|
19
|
+
from kunji.ui import (
|
|
20
|
+
Capped,
|
|
21
|
+
Column,
|
|
22
|
+
ResponsiveTable,
|
|
23
|
+
Steps,
|
|
24
|
+
StreamView,
|
|
25
|
+
Strip,
|
|
26
|
+
answer,
|
|
27
|
+
error,
|
|
28
|
+
get_console,
|
|
29
|
+
hint,
|
|
30
|
+
keys_line,
|
|
31
|
+
status_text,
|
|
32
|
+
success,
|
|
33
|
+
warn,
|
|
34
|
+
)
|
|
35
|
+
from kunji.ui.input import PROMPT_MESSAGE, SLASH_COMMANDS, chat_prompt
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def meta_line(result: TurnResult, cfg: Config) -> Text:
|
|
39
|
+
approx = "~" if result.usage.estimated else ""
|
|
40
|
+
parts = [
|
|
41
|
+
result.model,
|
|
42
|
+
f"{approx}{result.usage.input_tokens:,} in",
|
|
43
|
+
f"{approx}{result.usage.output_tokens:,} out",
|
|
44
|
+
fmt_money(cfg, result.cost_usd),
|
|
45
|
+
f"{result.elapsed:.1f}s",
|
|
46
|
+
]
|
|
47
|
+
if result.interrupted:
|
|
48
|
+
parts.append("stopped")
|
|
49
|
+
return Text(" " + " · ".join(parts), style="k.dim")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cached_models(provider_id: str) -> list[str]:
|
|
53
|
+
try:
|
|
54
|
+
return json.loads((cache_dir() / f"models_{provider_id}.json").read_text("utf-8"))
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_models_cache(provider_id: str, models: list[str]) -> None:
|
|
60
|
+
from kunji.config import atomic_write
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
atomic_write(cache_dir() / f"models_{provider_id}.json", json.dumps(models).encode())
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configured_refs(cfg: Config) -> list[str]:
|
|
69
|
+
refs: list[str] = []
|
|
70
|
+
for pid in all_specs(cfg):
|
|
71
|
+
entry = cfg.provider_entry(pid)
|
|
72
|
+
if entry.get("default_model"):
|
|
73
|
+
refs.append(f"{pid}:{entry['default_model']}")
|
|
74
|
+
refs += [f"{pid}:{m}" for m in cached_models(pid)[:200]]
|
|
75
|
+
return list(dict.fromkeys(refs))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ChatSession:
|
|
79
|
+
def __init__(self, cfg: Config, model_ref: str | None = None, system: str | None = None):
|
|
80
|
+
self.cfg = cfg
|
|
81
|
+
self.console = get_console()
|
|
82
|
+
self.spec, self.model = resolve_ref(cfg, model_ref)
|
|
83
|
+
self.system = system if system is not None else str(cfg.get("chat.system_prompt") or "")
|
|
84
|
+
self.messages: list[Message] = []
|
|
85
|
+
self.session_cost = 0.0
|
|
86
|
+
self.unpriced = 0
|
|
87
|
+
self.turns = 0
|
|
88
|
+
self.warned_budget: set[str] = set()
|
|
89
|
+
refresh_in_background(cfg)
|
|
90
|
+
checkin.check_updates_in_background(cfg)
|
|
91
|
+
self.catalog = Catalog(cfg)
|
|
92
|
+
|
|
93
|
+
# UI pieces ---------------------------------------------------------------
|
|
94
|
+
@property
|
|
95
|
+
def ref(self) -> str:
|
|
96
|
+
return f"{self.spec.id}:{self.model}"
|
|
97
|
+
|
|
98
|
+
def toolbar(self) -> str:
|
|
99
|
+
today = fmt_money(self.cfg, usage.spent_today_usd())
|
|
100
|
+
status = usage.check_budget(self.cfg, self.spec.id)
|
|
101
|
+
budget = ""
|
|
102
|
+
if status.state != "none":
|
|
103
|
+
budget = f" / {fmt_money(self.cfg, None, display_amount=status.limit)}"
|
|
104
|
+
return f" {self.ref} · today {today}{budget} · /help"
|
|
105
|
+
|
|
106
|
+
def header(self) -> None:
|
|
107
|
+
_, source = key_for(self.spec)
|
|
108
|
+
status = usage.check_budget(self.cfg, self.spec.id)
|
|
109
|
+
rows = [
|
|
110
|
+
("Provider", self.spec.name, 2),
|
|
111
|
+
("Model", self.model, 0),
|
|
112
|
+
("Key", "local" if not self.spec.needs_key else
|
|
113
|
+
("keychain" if source == "keychain" else source.replace("env:", "")), 4),
|
|
114
|
+
("Today", fmt_money(self.cfg, usage.spent_today_usd()), 3),
|
|
115
|
+
("State", status_text("READY"), 1),
|
|
116
|
+
]
|
|
117
|
+
if status.state != "none":
|
|
118
|
+
rows.insert(4, ("Budget", f"{fmt_money(self.cfg, None, display_amount=status.spent)}"
|
|
119
|
+
f" / {fmt_money(self.cfg, None, display_amount=status.limit)}", 3))
|
|
120
|
+
self.console.print(Strip(rows, title="kunji chat"))
|
|
121
|
+
self.console.print(Capped(keys_line([
|
|
122
|
+
("enter", "send"), ("alt+enter", "newline"), ("/", "commands"), ("ctrl+d", "quit"),
|
|
123
|
+
])))
|
|
124
|
+
self.console.print()
|
|
125
|
+
|
|
126
|
+
# Main loop -----------------------------------------------------------------
|
|
127
|
+
def run(self) -> None:
|
|
128
|
+
self.header()
|
|
129
|
+
history = ensure_dir(data_dir()) / "history.txt"
|
|
130
|
+
session = chat_prompt(str(history), lambda: configured_refs(self.cfg), self.toolbar)
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
text = session.prompt(PROMPT_MESSAGE)
|
|
134
|
+
except KeyboardInterrupt:
|
|
135
|
+
continue
|
|
136
|
+
except EOFError:
|
|
137
|
+
break
|
|
138
|
+
text = text.strip()
|
|
139
|
+
if not text:
|
|
140
|
+
continue
|
|
141
|
+
if text.startswith("/"):
|
|
142
|
+
if self.command(text) == "exit":
|
|
143
|
+
break
|
|
144
|
+
continue
|
|
145
|
+
self.send(text)
|
|
146
|
+
self.goodbye()
|
|
147
|
+
|
|
148
|
+
def goodbye(self) -> None:
|
|
149
|
+
spent = fmt_money(self.cfg, self.session_cost)
|
|
150
|
+
self.console.print(Text(f" {self.turns} replies · {spent} this session", style="k.dim"))
|
|
151
|
+
latest = checkin.cached_update_notice()
|
|
152
|
+
if latest:
|
|
153
|
+
hint(f" kunji {latest} is available — upgrade: pipx upgrade kunji")
|
|
154
|
+
|
|
155
|
+
# Sending -------------------------------------------------------------------
|
|
156
|
+
def budget_allows(self, spec: ProviderSpec) -> bool:
|
|
157
|
+
status = usage.check_budget(self.cfg, spec.id)
|
|
158
|
+
limit = fmt_money(self.cfg, None, display_amount=status.limit)
|
|
159
|
+
spent = fmt_money(self.cfg, None, display_amount=status.spent)
|
|
160
|
+
scope = "all providers" if status.scope == "all" else spec.name
|
|
161
|
+
if status.state == "block":
|
|
162
|
+
error(f"Daily budget reached for {scope}: {spent} / {limit}",
|
|
163
|
+
f"Raise it with: kunji budget set {status.scope} <amount>")
|
|
164
|
+
return False
|
|
165
|
+
if status.state == "warn" and status.scope not in self.warned_budget:
|
|
166
|
+
self.warned_budget.add(status.scope)
|
|
167
|
+
warn(f"80% of today's budget used for {scope}: {spent} / {limit}")
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
def send(self, text: str, already_added: bool = False) -> None:
|
|
171
|
+
if not self.budget_allows(self.spec):
|
|
172
|
+
return
|
|
173
|
+
if not already_added:
|
|
174
|
+
self.messages.append(Message("user", text))
|
|
175
|
+
result = self._answer_with_fallback()
|
|
176
|
+
if result is None:
|
|
177
|
+
self.messages.pop()
|
|
178
|
+
hint(" Your message was not sent. Press ↑ to edit and try again.")
|
|
179
|
+
return
|
|
180
|
+
if result.text:
|
|
181
|
+
self.messages.append(Message("assistant", result.text))
|
|
182
|
+
self.console.print(Capped(answer(result.text)))
|
|
183
|
+
self.console.print(meta_line(result, self.cfg))
|
|
184
|
+
self.console.print()
|
|
185
|
+
self.turns += 1
|
|
186
|
+
if result.cost_usd is None:
|
|
187
|
+
self.unpriced += 1
|
|
188
|
+
else:
|
|
189
|
+
self.session_cost += result.cost_usd
|
|
190
|
+
|
|
191
|
+
def _answer_with_fallback(self) -> TurnResult | None:
|
|
192
|
+
chain = fallback_chain(self.cfg, self.spec, self.model)
|
|
193
|
+
for index, (spec, model) in enumerate(chain):
|
|
194
|
+
view = StreamView()
|
|
195
|
+
try:
|
|
196
|
+
with Live(view, console=self.console, refresh_per_second=12, transient=True):
|
|
197
|
+
result = stream_turn(
|
|
198
|
+
self.cfg, spec, model, self.messages, self.system,
|
|
199
|
+
on_text=lambda t, v=view: setattr(v, "text", v.text + t),
|
|
200
|
+
catalog=self.catalog,
|
|
201
|
+
)
|
|
202
|
+
return result
|
|
203
|
+
except ProviderError as exc:
|
|
204
|
+
last = index == len(chain) - 1
|
|
205
|
+
if exc.retryable and not view.text and not last:
|
|
206
|
+
nxt = chain[index + 1]
|
|
207
|
+
warn(f"{exc.message}", f"Trying fallback {nxt[0].id}:{nxt[1]}")
|
|
208
|
+
continue
|
|
209
|
+
error(exc.message, exc.hint)
|
|
210
|
+
return None
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
# Slash commands ------------------------------------------------------------
|
|
214
|
+
def command(self, line: str) -> str | None:
|
|
215
|
+
name, _, arg = line.partition(" ")
|
|
216
|
+
arg = arg.strip()
|
|
217
|
+
handler = {
|
|
218
|
+
"/help": self.cmd_help,
|
|
219
|
+
"/model": self.cmd_model,
|
|
220
|
+
"/models": self.cmd_models,
|
|
221
|
+
"/system": self.cmd_system,
|
|
222
|
+
"/cost": self.cmd_cost,
|
|
223
|
+
"/retry": self.cmd_retry,
|
|
224
|
+
"/clear": self.cmd_clear,
|
|
225
|
+
"/save": self.cmd_save,
|
|
226
|
+
}.get(name)
|
|
227
|
+
if name in ("/exit", "/quit", "/q"):
|
|
228
|
+
return "exit"
|
|
229
|
+
if handler is None:
|
|
230
|
+
error(f"Unknown command {name}", "Type /help to see all commands")
|
|
231
|
+
return None
|
|
232
|
+
handler(arg)
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def cmd_help(self, _: str) -> None:
|
|
236
|
+
rows = [[cmd, desc] for cmd, desc in SLASH_COMMANDS.items()]
|
|
237
|
+
cols = [Column("Command", min_width=8), Column("What it does", min_width=12, ratio=1)]
|
|
238
|
+
self.console.print(ResponsiveTable(cols, rows, title="Commands"))
|
|
239
|
+
self.console.print(Capped(keys_line([
|
|
240
|
+
("enter", "send"), ("alt+enter / ctrl+j", "newline"), ("↑", "history"),
|
|
241
|
+
("ctrl+c", "stop reply"), ("ctrl+d", "quit"),
|
|
242
|
+
])))
|
|
243
|
+
self.console.print()
|
|
244
|
+
|
|
245
|
+
def cmd_model(self, arg: str) -> None:
|
|
246
|
+
if not arg:
|
|
247
|
+
refs = configured_refs(self.cfg)[:12]
|
|
248
|
+
hint(f" Current: {self.ref}")
|
|
249
|
+
if refs:
|
|
250
|
+
hint(" Try: " + " ".join(refs[:6]))
|
|
251
|
+
hint(" Usage: /model provider:model (tab completes)")
|
|
252
|
+
return
|
|
253
|
+
try:
|
|
254
|
+
spec, model = resolve_ref(self.cfg, arg)
|
|
255
|
+
except ProviderError as exc:
|
|
256
|
+
error(exc.message, exc.hint)
|
|
257
|
+
return
|
|
258
|
+
if spec.needs_key and not key_for(spec)[0]:
|
|
259
|
+
error(f"No API key for {spec.name}", f"Add one: kunji keys add {spec.id}")
|
|
260
|
+
return
|
|
261
|
+
self.spec, self.model = spec, model
|
|
262
|
+
success(f"Switched to {self.ref}", "conversation kept")
|
|
263
|
+
|
|
264
|
+
def cmd_models(self, arg: str) -> None:
|
|
265
|
+
try:
|
|
266
|
+
with Steps() as steps, make_provider(self.spec) as provider:
|
|
267
|
+
models = steps.run(f"Fetching models from {self.spec.name}",
|
|
268
|
+
provider.list_models, done=lambda m: f"{len(m)} models")
|
|
269
|
+
except ProviderError as exc:
|
|
270
|
+
error(exc.message, exc.hint)
|
|
271
|
+
return
|
|
272
|
+
save_models_cache(self.spec.id, models)
|
|
273
|
+
shown = [m for m in models if arg.lower() in m.lower()] if arg else models
|
|
274
|
+
rows = [[m, "current" if m == self.model else ""] for m in shown[:40]]
|
|
275
|
+
selected = next((i for i, r in enumerate(rows) if r[0] == self.model), None)
|
|
276
|
+
self.console.print(ResponsiveTable(
|
|
277
|
+
[Column("Model", min_width=12, ratio=1), Column("", priority=1, min_width=7)],
|
|
278
|
+
rows, selected=selected, title=f"{self.spec.name} models"))
|
|
279
|
+
if len(shown) > 40:
|
|
280
|
+
hint(f" …and {len(shown) - 40} more. Filter: /models <text>")
|
|
281
|
+
hint(" Switch: /model " + self.spec.id + ":<model>")
|
|
282
|
+
|
|
283
|
+
def cmd_system(self, arg: str) -> None:
|
|
284
|
+
if not arg:
|
|
285
|
+
hint(f" System prompt: {self.system or '(none)'}")
|
|
286
|
+
hint(" Set: /system You are a concise assistant. Clear: /system off")
|
|
287
|
+
return
|
|
288
|
+
self.system = "" if arg.lower() in {"off", "none", "clear"} else arg
|
|
289
|
+
success("System prompt cleared" if not self.system else "System prompt updated")
|
|
290
|
+
|
|
291
|
+
def cmd_cost(self, _: str) -> None:
|
|
292
|
+
status = usage.check_budget(self.cfg, self.spec.id)
|
|
293
|
+
rows = [
|
|
294
|
+
("Session", fmt_money(self.cfg, self.session_cost) +
|
|
295
|
+
(f" +{self.unpriced}?" if self.unpriced else ""), 0),
|
|
296
|
+
(f"Today {self.spec.id}", fmt_money(self.cfg, usage.spent_today_usd(self.spec.id)), 2),
|
|
297
|
+
("Today all", fmt_money(self.cfg, usage.spent_today_usd()), 1),
|
|
298
|
+
]
|
|
299
|
+
if status.state != "none":
|
|
300
|
+
rows.append(("Budget", fmt_money(self.cfg, None, display_amount=status.limit)
|
|
301
|
+
+ f" ({status.scope})", 3))
|
|
302
|
+
price = self.catalog.price_for(self.spec.id, self.model, self.spec.openrouter_prefix)
|
|
303
|
+
rows.append(("Price / 1M", "unknown" if price is None else
|
|
304
|
+
f"{fmt_money(self.cfg, price.input)} in · "
|
|
305
|
+
f"{fmt_money(self.cfg, price.output)} out", 4))
|
|
306
|
+
self.console.print(Strip(rows, title="Cost"))
|
|
307
|
+
if price is None:
|
|
308
|
+
hint(" No known price for this model. Set one: see docs/CONFIG.md (pricing)")
|
|
309
|
+
|
|
310
|
+
def cmd_retry(self, _: str) -> None:
|
|
311
|
+
if not self.messages:
|
|
312
|
+
hint(" Nothing to retry yet.")
|
|
313
|
+
return
|
|
314
|
+
if self.messages[-1].role == "assistant":
|
|
315
|
+
self.messages.pop()
|
|
316
|
+
if self.messages and self.messages[-1].role == "user":
|
|
317
|
+
self.send(self.messages[-1].content, already_added=True)
|
|
318
|
+
|
|
319
|
+
def cmd_clear(self, _: str) -> None:
|
|
320
|
+
self.messages.clear()
|
|
321
|
+
success("Started a fresh conversation")
|
|
322
|
+
|
|
323
|
+
def cmd_save(self, arg: str) -> None:
|
|
324
|
+
if not self.messages:
|
|
325
|
+
hint(" Nothing to save yet.")
|
|
326
|
+
return
|
|
327
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M")
|
|
328
|
+
path = Path(arg or f"kunji-chat-{stamp}.md").expanduser()
|
|
329
|
+
lines = [f"# kunji chat · {self.ref} · {time.strftime('%Y-%m-%d %H:%M')}", ""]
|
|
330
|
+
for m in self.messages:
|
|
331
|
+
lines += [f"## {'You' if m.role == 'user' else 'Assistant'}", "", m.content, ""]
|
|
332
|
+
try:
|
|
333
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
error(f"Could not save: {exc}")
|
|
336
|
+
return
|
|
337
|
+
success(f"Saved to {path}")
|