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 ADDED
@@ -0,0 +1,3 @@
1
+ """Kunji — bring your own key, use every AI model from one terminal."""
2
+
3
+ __version__ = "0.1.0"
kunji/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from kunji.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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}")