synth-ai 0.2.2.dev0__py3-none-any.whl → 0.2.3__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.
- synth_ai/cli/__init__.py +66 -0
- synth_ai/cli/balance.py +205 -0
- synth_ai/cli/calc.py +70 -0
- synth_ai/cli/demo.py +74 -0
- synth_ai/{cli.py → cli/legacy_root_backup.py} +60 -15
- synth_ai/cli/man.py +103 -0
- synth_ai/cli/recent.py +126 -0
- synth_ai/cli/root.py +184 -0
- synth_ai/cli/status.py +126 -0
- synth_ai/cli/traces.py +136 -0
- synth_ai/cli/watch.py +508 -0
- synth_ai/config/base_url.py +53 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +252 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_duckdb_v2_backup.py +413 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +646 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_synth.py +34 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth.py +1740 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth_v2_backup.py +1318 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_duckdb_v2_backup.py +386 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +580 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v2_backup.py +1352 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/test_crafter_react_agent_openai_v2_backup.py +2551 -0
- synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +1 -1
- synth_ai/environments/examples/crafter_classic/agent_demos/old/traces/session_crafter_episode_16_15227b68-2906-416f-acc4-d6a9b4fa5828_20250725_001154.json +1363 -1
- synth_ai/environments/examples/crafter_classic/agent_demos/test_crafter_react_agent.py +3 -3
- synth_ai/environments/examples/enron/dataset/corbt___enron_emails_sample_questions/default/0.0.0/293c9fe8170037e01cc9cf5834e0cd5ef6f1a6bb/dataset_info.json +1 -0
- synth_ai/environments/examples/nethack/helpers/achievements.json +64 -0
- synth_ai/environments/examples/red/units/test_exploration_strategy.py +1 -1
- synth_ai/environments/examples/red/units/test_menu_bug_reproduction.py +5 -5
- synth_ai/environments/examples/red/units/test_movement_debug.py +2 -2
- synth_ai/environments/examples/red/units/test_retry_movement.py +1 -1
- synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/available_envs.json +122 -0
- synth_ai/environments/examples/sokoban/verified_puzzles.json +54987 -0
- synth_ai/experimental/synth_oss.py +446 -0
- synth_ai/learning/core.py +21 -0
- synth_ai/learning/gateway.py +4 -0
- synth_ai/learning/prompts/mipro.py +0 -0
- synth_ai/lm/__init__.py +3 -0
- synth_ai/lm/core/main.py +4 -0
- synth_ai/lm/core/main_v3.py +68 -13
- synth_ai/lm/core/vendor_clients.py +4 -0
- synth_ai/lm/provider_support/openai.py +11 -2
- synth_ai/lm/vendors/base.py +7 -0
- synth_ai/lm/vendors/openai_standard.py +339 -4
- synth_ai/lm/vendors/openai_standard_responses.py +243 -0
- synth_ai/lm/vendors/synth_client.py +155 -5
- synth_ai/lm/warmup.py +54 -17
- synth_ai/tracing/__init__.py +18 -0
- synth_ai/tracing_v1/__init__.py +29 -14
- synth_ai/tracing_v3/config.py +13 -7
- synth_ai/tracing_v3/db_config.py +6 -6
- synth_ai/tracing_v3/turso/manager.py +8 -8
- synth_ai/tui/__main__.py +13 -0
- synth_ai/tui/dashboard.py +329 -0
- synth_ai/v0/tracing/__init__.py +0 -0
- synth_ai/{tracing → v0/tracing}/base_client.py +3 -3
- synth_ai/{tracing → v0/tracing}/client_manager.py +1 -1
- synth_ai/{tracing → v0/tracing}/context.py +1 -1
- synth_ai/{tracing → v0/tracing}/decorators.py +11 -11
- synth_ai/v0/tracing/events/__init__.py +0 -0
- synth_ai/{tracing → v0/tracing}/events/manage.py +4 -4
- synth_ai/{tracing → v0/tracing}/events/scope.py +6 -6
- synth_ai/{tracing → v0/tracing}/events/store.py +3 -3
- synth_ai/{tracing → v0/tracing}/immediate_client.py +6 -6
- synth_ai/{tracing → v0/tracing}/log_client_base.py +2 -2
- synth_ai/{tracing → v0/tracing}/retry_queue.py +3 -3
- synth_ai/{tracing → v0/tracing}/trackers.py +2 -2
- synth_ai/{tracing → v0/tracing}/upload.py +4 -4
- synth_ai/v0/tracing_v1/__init__.py +16 -0
- synth_ai/{tracing_v1 → v0/tracing_v1}/base_client.py +3 -3
- synth_ai/{tracing_v1 → v0/tracing_v1}/client_manager.py +1 -1
- synth_ai/{tracing_v1 → v0/tracing_v1}/context.py +1 -1
- synth_ai/{tracing_v1 → v0/tracing_v1}/decorators.py +11 -11
- synth_ai/v0/tracing_v1/events/__init__.py +0 -0
- synth_ai/{tracing_v1 → v0/tracing_v1}/events/manage.py +4 -4
- synth_ai/{tracing_v1 → v0/tracing_v1}/events/scope.py +6 -6
- synth_ai/{tracing_v1 → v0/tracing_v1}/events/store.py +3 -3
- synth_ai/{tracing_v1 → v0/tracing_v1}/immediate_client.py +6 -6
- synth_ai/{tracing_v1 → v0/tracing_v1}/log_client_base.py +2 -2
- synth_ai/{tracing_v1 → v0/tracing_v1}/retry_queue.py +3 -3
- synth_ai/{tracing_v1 → v0/tracing_v1}/trackers.py +2 -2
- synth_ai/{tracing_v1 → v0/tracing_v1}/upload.py +4 -4
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/METADATA +98 -4
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/RECORD +98 -62
- /synth_ai/{tracing/events/__init__.py → environments/examples/crafter_classic/debug_translation.py} +0 -0
- /synth_ai/{tracing_v1/events/__init__.py → learning/prompts/gepa.py} +0 -0
- /synth_ai/{tracing → v0/tracing}/abstractions.py +0 -0
- /synth_ai/{tracing → v0/tracing}/config.py +0 -0
- /synth_ai/{tracing → v0/tracing}/local.py +0 -0
- /synth_ai/{tracing → v0/tracing}/utils.py +0 -0
- /synth_ai/{tracing_v1 → v0/tracing_v1}/abstractions.py +0 -0
- /synth_ai/{tracing_v1 → v0/tracing_v1}/config.py +0 -0
- /synth_ai/{tracing_v1 → v0/tracing_v1}/local.py +0 -0
- /synth_ai/{tracing_v1 → v0/tracing_v1}/utils.py +0 -0
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.3.dist-info}/top_level.txt +0 -0
synth_ai/cli/__init__.py
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
"""CLI subcommands for Synth AI.
|
2
|
+
|
3
|
+
This package hosts modular commands (watch, traces, recent, calc, status)
|
4
|
+
and exposes a top-level Click group named `cli` compatible with the
|
5
|
+
pyproject entry point `synth_ai.cli:cli`.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from __future__ import annotations
|
9
|
+
|
10
|
+
import importlib.util
|
11
|
+
import pathlib
|
12
|
+
import sys
|
13
|
+
|
14
|
+
# Load environment variables from a local .env if present (repo root)
|
15
|
+
try:
|
16
|
+
from dotenv import load_dotenv, find_dotenv
|
17
|
+
# Source .env early so CLI subcommands inherit config; do not override shell
|
18
|
+
load_dotenv(find_dotenv(usecwd=True), override=False)
|
19
|
+
except Exception:
|
20
|
+
# dotenv is optional at runtime; proceed if unavailable
|
21
|
+
pass
|
22
|
+
|
23
|
+
|
24
|
+
from .root import cli # new canonical CLI entrypoint
|
25
|
+
|
26
|
+
# Register subcommands from this package onto the group
|
27
|
+
try:
|
28
|
+
from . import watch as _watch
|
29
|
+
_watch.register(cli)
|
30
|
+
except Exception:
|
31
|
+
pass
|
32
|
+
try:
|
33
|
+
from . import balance as _balance
|
34
|
+
_balance.register(cli)
|
35
|
+
except Exception:
|
36
|
+
pass
|
37
|
+
try:
|
38
|
+
from . import man as _man
|
39
|
+
_man.register(cli)
|
40
|
+
except Exception:
|
41
|
+
pass
|
42
|
+
try:
|
43
|
+
from . import traces as _traces
|
44
|
+
_traces.register(cli)
|
45
|
+
except Exception:
|
46
|
+
pass
|
47
|
+
try:
|
48
|
+
from . import recent as _recent
|
49
|
+
_recent.register(cli)
|
50
|
+
except Exception:
|
51
|
+
pass
|
52
|
+
try:
|
53
|
+
from . import calc as _calc
|
54
|
+
_calc.register(cli)
|
55
|
+
except Exception:
|
56
|
+
pass
|
57
|
+
try:
|
58
|
+
from . import status as _status
|
59
|
+
_status.register(cli)
|
60
|
+
except Exception:
|
61
|
+
pass
|
62
|
+
try:
|
63
|
+
from . import demo as _demo
|
64
|
+
_demo.register(cli)
|
65
|
+
except Exception:
|
66
|
+
pass
|
synth_ai/cli/balance.py
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
CLI: check remaining credit balance from Synth backend.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import os
|
9
|
+
import click
|
10
|
+
import requests
|
11
|
+
from requests import Response
|
12
|
+
from urllib.parse import urlparse
|
13
|
+
from rich.console import Console
|
14
|
+
from rich.panel import Panel
|
15
|
+
from rich.table import Table
|
16
|
+
from rich import box
|
17
|
+
|
18
|
+
|
19
|
+
PROD_BACKEND_BASE = "https://agent-learning.onrender.com/api/v1"
|
20
|
+
|
21
|
+
|
22
|
+
def _get_default_base_url() -> str:
|
23
|
+
# Prefer explicit backend variables that are NOT modal; else default to prod backend
|
24
|
+
for var in ("SYNTH_BACKEND_BASE_URL", "BACKEND_BASE_URL", "SYNTH_BASE_URL"):
|
25
|
+
val = os.getenv(var)
|
26
|
+
if val and ("modal" not in val.lower() and "modal.run" not in val.lower()):
|
27
|
+
return val
|
28
|
+
return PROD_BACKEND_BASE
|
29
|
+
|
30
|
+
|
31
|
+
def _ensure_api_v1_prefix(base_url: str) -> str:
|
32
|
+
"""Ensure the base URL includes the /api/v1 prefix.
|
33
|
+
|
34
|
+
Accepts either a full prefix (http://host:port/api/v1) or a root
|
35
|
+
service URL (http://host:port). If no '/api' segment is present, append
|
36
|
+
'/api/v1'.
|
37
|
+
"""
|
38
|
+
b = base_url.rstrip("/")
|
39
|
+
if b.endswith("/api") or b.endswith("/api/v1") or "/api/" in b:
|
40
|
+
return b
|
41
|
+
return b + "/api/v1"
|
42
|
+
|
43
|
+
|
44
|
+
def _resolve_api_key(explicit_key: str | None) -> tuple[str | None, str | None]:
|
45
|
+
if explicit_key:
|
46
|
+
return explicit_key, "--api-key"
|
47
|
+
# Try multiple env vars commonly used in this repo
|
48
|
+
for var in ("SYNTH_BACKEND_API_KEY", "SYNTH_API_KEY", "DEFAULT_DEV_API_KEY"):
|
49
|
+
val = os.getenv(var)
|
50
|
+
if val:
|
51
|
+
return val, var
|
52
|
+
return None, None
|
53
|
+
|
54
|
+
|
55
|
+
def _auth_headers(api_key: str | None) -> dict[str, str]:
|
56
|
+
key, _ = _resolve_api_key(api_key)
|
57
|
+
if not key:
|
58
|
+
return {}
|
59
|
+
return {"Authorization": f"Bearer {key}"}
|
60
|
+
|
61
|
+
|
62
|
+
def register(cli):
|
63
|
+
@cli.command()
|
64
|
+
@click.option(
|
65
|
+
"--base-url",
|
66
|
+
default=_get_default_base_url,
|
67
|
+
show_default=True,
|
68
|
+
help="Synth backend base URL (prefix like http://host:port/api/v1)",
|
69
|
+
)
|
70
|
+
@click.option(
|
71
|
+
"--api-key",
|
72
|
+
envvar="SYNTH_API_KEY",
|
73
|
+
help="API key for the Synth backend (or set SYNTH_API_KEY)",
|
74
|
+
)
|
75
|
+
@click.option(
|
76
|
+
"--usage/--no-usage",
|
77
|
+
default=False,
|
78
|
+
help="Also fetch recent usage summary",
|
79
|
+
)
|
80
|
+
def balance(base_url: str, api_key: str | None, usage: bool):
|
81
|
+
"""Show your remaining credit balance from the Synth backend."""
|
82
|
+
console = Console()
|
83
|
+
|
84
|
+
key_val, key_src = _resolve_api_key(api_key)
|
85
|
+
if not key_val:
|
86
|
+
console.print(
|
87
|
+
"[red]Missing API key.[/red] Set via --api-key or SYNTH_API_KEY env var."
|
88
|
+
)
|
89
|
+
return
|
90
|
+
|
91
|
+
base = _ensure_api_v1_prefix(base_url)
|
92
|
+
|
93
|
+
# Hard guard: never hit Modal URLs for account balance
|
94
|
+
try:
|
95
|
+
parsed = urlparse(base)
|
96
|
+
host = (parsed.hostname or "").lower()
|
97
|
+
except Exception:
|
98
|
+
host = ""
|
99
|
+
if "modal" in host or "modal.run" in base.lower():
|
100
|
+
# Override to prod backend unconditionally
|
101
|
+
fallback = PROD_BACKEND_BASE
|
102
|
+
console.print(
|
103
|
+
f"[yellow]Detected remote Modal URL ({base}). Using backend instead:[/yellow] {fallback}"
|
104
|
+
)
|
105
|
+
base = fallback
|
106
|
+
|
107
|
+
try:
|
108
|
+
resp: Response = requests.get(
|
109
|
+
f"{base}/balance/current",
|
110
|
+
headers=_auth_headers(api_key),
|
111
|
+
timeout=10,
|
112
|
+
)
|
113
|
+
resp.raise_for_status()
|
114
|
+
data = resp.json()
|
115
|
+
cents = int(data.get("balance_cents", 0))
|
116
|
+
dollars = float(data.get("balance_dollars", cents / 100.0))
|
117
|
+
console.print(f"Balance: [bold]${dollars:,.2f}[/bold]")
|
118
|
+
|
119
|
+
# Try to print compact spend summary for 24h and 7d
|
120
|
+
try:
|
121
|
+
u: Response = requests.get(
|
122
|
+
f"{base}/balance/usage/windows",
|
123
|
+
params={"hours": "24,168"},
|
124
|
+
headers=_auth_headers(api_key),
|
125
|
+
timeout=10,
|
126
|
+
)
|
127
|
+
if u.ok:
|
128
|
+
uj = u.json()
|
129
|
+
rows = uj.get("windows", [])
|
130
|
+
windows = {int(r.get("window_hours")): r for r in rows if isinstance(r.get("window_hours"), int)}
|
131
|
+
def _usd(c):
|
132
|
+
try:
|
133
|
+
return f"${(int(c)/100):,.2f}"
|
134
|
+
except Exception:
|
135
|
+
return "$0.00"
|
136
|
+
if 24 in windows or 168 in windows:
|
137
|
+
t = Table(title="Spend (Tokens vs GPU)", box=box.SIMPLE, header_style="bold")
|
138
|
+
t.add_column("Window")
|
139
|
+
t.add_column("Tokens", justify="right")
|
140
|
+
t.add_column("GPU", justify="right")
|
141
|
+
t.add_column("Total", justify="right")
|
142
|
+
for h,label in ((24,"24h"),(168,"7d")):
|
143
|
+
if h in windows:
|
144
|
+
w = windows[h]
|
145
|
+
t.add_row(
|
146
|
+
label,
|
147
|
+
_usd(w.get("token_spend_cents", 0)),
|
148
|
+
_usd(w.get("gpu_spend_cents", 0)),
|
149
|
+
_usd(w.get("total_spend_cents", 0)),
|
150
|
+
)
|
151
|
+
console.print(t)
|
152
|
+
elif usage:
|
153
|
+
# Fallback to older summary if requested explicitly
|
154
|
+
u2: Response = requests.get(
|
155
|
+
f"{base}/balance/usage",
|
156
|
+
headers=_auth_headers(api_key),
|
157
|
+
timeout=10,
|
158
|
+
)
|
159
|
+
if u2.ok:
|
160
|
+
uj = u2.json()
|
161
|
+
cm = uj.get("current_month", {})
|
162
|
+
l30 = uj.get("last_30_days", {})
|
163
|
+
t = Table(title="Usage Summary", box=box.SIMPLE, header_style="bold")
|
164
|
+
t.add_column("Window")
|
165
|
+
t.add_column("Token Spend", justify="right")
|
166
|
+
t.add_column("GPU Spend", justify="right")
|
167
|
+
t.add_column("Total", justify="right")
|
168
|
+
t.add_row(
|
169
|
+
"Current Month",
|
170
|
+
f"${(cm.get('token_spend_cents',0)/100):,.2f}",
|
171
|
+
f"${(cm.get('gpu_spend_cents',0)/100):,.2f}",
|
172
|
+
f"${(cm.get('total_spend_cents',0)/100):,.2f}",
|
173
|
+
)
|
174
|
+
t.add_row(
|
175
|
+
"Last 30 days",
|
176
|
+
f"${(l30.get('token_spend_cents',0)/100):,.2f}",
|
177
|
+
f"${(l30.get('gpu_spend_cents',0)/100):,.2f}",
|
178
|
+
f"${(l30.get('total_spend_cents',0)/100):,.2f}",
|
179
|
+
)
|
180
|
+
console.print(t)
|
181
|
+
except Exception:
|
182
|
+
# Silent failure on usage summary
|
183
|
+
pass
|
184
|
+
|
185
|
+
except requests.HTTPError as e:
|
186
|
+
try:
|
187
|
+
detail = e.response.json().get("detail") if e.response else None
|
188
|
+
except Exception:
|
189
|
+
detail = None
|
190
|
+
if e.response is not None and e.response.status_code == 401:
|
191
|
+
key_dbg, key_src = _resolve_api_key(api_key)
|
192
|
+
shown = (key_dbg[:6] + "…" + key_dbg[-4:]) if key_dbg else "<none>"
|
193
|
+
console.print(
|
194
|
+
"[red]Unauthorized (401).[/red] The API key was not accepted by the backend."
|
195
|
+
)
|
196
|
+
console.print(
|
197
|
+
f"- Using base URL: {base}\n- API key (masked): {shown}\n- Key source: {key_src or '<none>'}\n- Ensure this key exists in the backend DB (table api_keys) and is active."
|
198
|
+
)
|
199
|
+
console.print(
|
200
|
+
"If running locally, you can seed a dev key by setting ENVIRONMENT=dev and ensuring the DB has no API keys (auto-seed path), or create one via your admin path."
|
201
|
+
)
|
202
|
+
else:
|
203
|
+
console.print(f"[red]HTTP error:[/red] {e} {detail or ''}")
|
204
|
+
except Exception as e:
|
205
|
+
console.print(f"[red]Error:[/red] {e}")
|
synth_ai/cli/calc.py
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
CLI: basic calculator for quick math in terminal.
|
4
|
+
Safe evaluation of arithmetic expressions.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import ast
|
8
|
+
import operator as op
|
9
|
+
import click
|
10
|
+
from rich.console import Console
|
11
|
+
|
12
|
+
|
13
|
+
# Supported operators
|
14
|
+
_OPS = {
|
15
|
+
ast.Add: op.add,
|
16
|
+
ast.Sub: op.sub,
|
17
|
+
ast.Mult: op.mul,
|
18
|
+
ast.Div: op.truediv,
|
19
|
+
ast.FloorDiv: op.floordiv,
|
20
|
+
ast.Mod: op.mod,
|
21
|
+
ast.Pow: op.pow,
|
22
|
+
ast.USub: op.neg,
|
23
|
+
ast.UAdd: op.pos,
|
24
|
+
}
|
25
|
+
|
26
|
+
|
27
|
+
def _safe_eval(expr: str) -> float:
|
28
|
+
node = ast.parse(expr, mode="eval")
|
29
|
+
|
30
|
+
def _eval(n):
|
31
|
+
if isinstance(n, ast.Expression):
|
32
|
+
return _eval(n.body)
|
33
|
+
if isinstance(n, ast.Num): # 3.8 and earlier
|
34
|
+
return n.n
|
35
|
+
if isinstance(n, ast.Constant): # 3.8+
|
36
|
+
if isinstance(n.value, (int, float)):
|
37
|
+
return n.value
|
38
|
+
raise ValueError("Only numeric constants are allowed")
|
39
|
+
if isinstance(n, ast.BinOp) and type(n.op) in _OPS:
|
40
|
+
return _OPS[type(n.op)](_eval(n.left), _eval(n.right))
|
41
|
+
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS:
|
42
|
+
return _OPS[type(n.op)](_eval(n.operand))
|
43
|
+
if isinstance(n, ast.Expr):
|
44
|
+
return _eval(n.value)
|
45
|
+
raise ValueError("Unsupported expression")
|
46
|
+
|
47
|
+
return _eval(node)
|
48
|
+
|
49
|
+
|
50
|
+
def register(cli):
|
51
|
+
@cli.command(name="calc")
|
52
|
+
@click.argument("expr", nargs=-1)
|
53
|
+
def calc(expr: tuple[str, ...]):
|
54
|
+
"""Evaluate a basic math expression, e.g., "(12_345 + 6789) / 100".
|
55
|
+
|
56
|
+
Supports + - * / // % ** and parentheses. No variables or functions.
|
57
|
+
"""
|
58
|
+
console = Console()
|
59
|
+
expression = " ".join(expr).strip()
|
60
|
+
if not expression:
|
61
|
+
console.print("[dim]Usage:[/dim] synth-ai calc '2*(3+4)' or: uvx . calc 2 + 2")
|
62
|
+
return
|
63
|
+
try:
|
64
|
+
# Allow underscores in numbers for readability
|
65
|
+
expression = expression.replace("_", "")
|
66
|
+
result = _safe_eval(expression)
|
67
|
+
console.print(f"= [bold]{result}[/bold]")
|
68
|
+
except Exception as e:
|
69
|
+
console.print(f"[red]Error:[/red] {e}")
|
70
|
+
|
synth_ai/cli/demo.py
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
CLI: interactive launcher for example demos.
|
4
|
+
|
5
|
+
Finds all `run_demo.sh` scripts under `examples/` and lets the user pick one
|
6
|
+
to run. Intended to be used as: `uvx synth-ai demo` or `synth-ai demo`.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from __future__ import annotations
|
10
|
+
|
11
|
+
import os
|
12
|
+
import subprocess
|
13
|
+
from pathlib import Path
|
14
|
+
from typing import List
|
15
|
+
|
16
|
+
import click
|
17
|
+
|
18
|
+
|
19
|
+
def _find_demo_scripts(root: Path) -> List[Path]:
|
20
|
+
if not root.exists():
|
21
|
+
return []
|
22
|
+
return sorted([p for p in root.rglob("run_demo.sh") if p.is_file()])
|
23
|
+
|
24
|
+
|
25
|
+
def register(cli):
|
26
|
+
@cli.command()
|
27
|
+
@click.option("--list", "list_only", is_flag=True, help="List available demos and exit")
|
28
|
+
@click.option("-f", "filter_term", default="", help="Filter demos by substring")
|
29
|
+
def demo(list_only: bool, filter_term: str):
|
30
|
+
"""Launch an interactive demo from examples/"""
|
31
|
+
repo_root = Path(os.getcwd())
|
32
|
+
examples_dir = repo_root / "examples"
|
33
|
+
demos = _find_demo_scripts(examples_dir)
|
34
|
+
if filter_term:
|
35
|
+
demos = [p for p in demos if filter_term.lower() in str(p).lower()]
|
36
|
+
|
37
|
+
if not demos:
|
38
|
+
click.echo("No run_demo.sh scripts found under examples/.")
|
39
|
+
return
|
40
|
+
|
41
|
+
if list_only:
|
42
|
+
click.echo("Available demos:")
|
43
|
+
for p in demos:
|
44
|
+
click.echo(f" - {p.relative_to(repo_root)}")
|
45
|
+
return
|
46
|
+
|
47
|
+
click.echo("Available demos:")
|
48
|
+
for idx, p in enumerate(demos, start=1):
|
49
|
+
click.echo(f" {idx}. {p.relative_to(repo_root)}")
|
50
|
+
click.echo("")
|
51
|
+
|
52
|
+
def _validate_choice(val: str) -> int:
|
53
|
+
try:
|
54
|
+
i = int(val)
|
55
|
+
except Exception:
|
56
|
+
raise click.BadParameter("Enter a number from the list")
|
57
|
+
if i < 1 or i > len(demos):
|
58
|
+
raise click.BadParameter(f"Choose a number between 1 and {len(demos)}")
|
59
|
+
return i
|
60
|
+
|
61
|
+
choice = click.prompt("Select a demo to run", value_proc=_validate_choice)
|
62
|
+
script = demos[choice - 1]
|
63
|
+
|
64
|
+
click.echo("")
|
65
|
+
click.echo(f"🚀 Running {script.relative_to(repo_root)}\n")
|
66
|
+
|
67
|
+
# Run via bash to avoid relying on executable bit; inherit environment
|
68
|
+
try:
|
69
|
+
subprocess.run(["bash", str(script)], check=True)
|
70
|
+
except subprocess.CalledProcessError as e:
|
71
|
+
click.echo(f"❌ Demo exited with non-zero status: {e.returncode}")
|
72
|
+
except KeyboardInterrupt:
|
73
|
+
click.echo("\n🛑 Demo interrupted by user")
|
74
|
+
|
@@ -9,7 +9,6 @@ import subprocess
|
|
9
9
|
import signal
|
10
10
|
import time
|
11
11
|
import shutil
|
12
|
-
import asyncio
|
13
12
|
from pathlib import Path
|
14
13
|
from typing import Optional
|
15
14
|
import logging
|
@@ -228,13 +227,33 @@ def unregister_env(name: str, service_url: str):
|
|
228
227
|
click.echo(f"❌ Error: {e}")
|
229
228
|
|
230
229
|
|
230
|
+
@cli.command()
|
231
|
+
@click.option("--url", default="sqlite+aiosqlite:///./synth_ai.db/dbs/default/data", help="Database URL")
|
232
|
+
def view(url: str):
|
233
|
+
"""Launch the interactive TUI dashboard."""
|
234
|
+
try:
|
235
|
+
from .tui.dashboard import SynthDashboard
|
236
|
+
app = SynthDashboard(db_url=url)
|
237
|
+
app.run()
|
238
|
+
except ImportError:
|
239
|
+
click.echo("❌ Textual not installed. Install with: pip install textual", err=True)
|
240
|
+
sys.exit(1)
|
241
|
+
except KeyboardInterrupt:
|
242
|
+
click.echo("\n👋 Dashboard closed", err=True)
|
243
|
+
|
244
|
+
# Note: subcommands (watch, experiments, experiment, usage, traces, status, recent, calc)
|
245
|
+
# are registered from the package module synth_ai.cli at import time.
|
246
|
+
|
247
|
+
|
231
248
|
@cli.command()
|
232
249
|
@click.option("--db-file", default="synth_ai.db", help="Database file path")
|
233
250
|
@click.option("--sqld-port", default=8080, type=int, help="Port for sqld HTTP interface")
|
234
251
|
@click.option("--env-port", default=8901, type=int, help="Port for environment service")
|
235
252
|
@click.option("--no-sqld", is_flag=True, help="Skip starting sqld daemon")
|
236
253
|
@click.option("--no-env", is_flag=True, help="Skip starting environment service")
|
237
|
-
|
254
|
+
@click.option("--reload/--no-reload", default=False, help="Enable auto-reload (default: off). Or set SYNTH_RELOAD=1")
|
255
|
+
@click.option("--force", is_flag=True, help="Kill any process already bound to --env-port without prompting")
|
256
|
+
def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bool, reload: bool, force: bool):
|
238
257
|
"""Start Synth AI services (sqld daemon and environment service)."""
|
239
258
|
|
240
259
|
# Configure logging
|
@@ -330,18 +349,38 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
|
|
330
349
|
# Running from source
|
331
350
|
env_module = "synth_ai.environments.service.app:app"
|
332
351
|
|
333
|
-
#
|
352
|
+
# Ensure env_port is free; offer to kill existing listeners
|
334
353
|
try:
|
335
354
|
import socket
|
336
355
|
|
337
356
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
357
|
+
in_use = s.connect_ex(("127.0.0.1", env_port)) == 0
|
358
|
+
except Exception:
|
359
|
+
in_use = False
|
360
|
+
|
361
|
+
if in_use:
|
362
|
+
# Try to find PIDs using lsof (macOS/Linux)
|
363
|
+
pids: list[str] = []
|
364
|
+
try:
|
365
|
+
out = subprocess.run(["lsof", "-ti", f":{env_port}"], capture_output=True, text=True)
|
366
|
+
if out.returncode == 0 and out.stdout.strip():
|
367
|
+
pids = [p for p in out.stdout.strip().splitlines() if p]
|
368
|
+
except FileNotFoundError:
|
369
|
+
pids = []
|
370
|
+
|
371
|
+
if force:
|
372
|
+
if pids:
|
373
|
+
subprocess.run(["kill", "-9", *pids], check=False)
|
374
|
+
time.sleep(0.5)
|
375
|
+
else:
|
376
|
+
pid_info = f" PIDs: {', '.join(pids)}" if pids else ""
|
377
|
+
if click.confirm(f"⚠️ Port {env_port} is in use.{pid_info} Kill and continue?", default=True):
|
378
|
+
if pids:
|
379
|
+
subprocess.run(["kill", "-9", *pids], check=False)
|
380
|
+
time.sleep(0.5)
|
381
|
+
else:
|
382
|
+
click.echo("❌ Aborting. Re-run with --force to auto-kill or choose a different --env-port.")
|
383
|
+
sys.exit(1)
|
345
384
|
|
346
385
|
# Set environment variables
|
347
386
|
env = os.environ.copy()
|
@@ -358,7 +397,12 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
|
|
358
397
|
click.echo("💡 Tips:")
|
359
398
|
click.echo(" - Check sqld.log if database issues occur")
|
360
399
|
click.echo(" - Use Ctrl+C to stop all services")
|
361
|
-
|
400
|
+
# Determine reload behavior: CLI flag overrides env var, default is off
|
401
|
+
reload_enabled = reload or (os.getenv("SYNTH_RELOAD", "0") == "1")
|
402
|
+
if reload_enabled:
|
403
|
+
click.echo(" - Auto-reload ENABLED (code changes restart service)")
|
404
|
+
else:
|
405
|
+
click.echo(" - Auto-reload DISABLED (stable in-memory sessions)")
|
362
406
|
click.echo("")
|
363
407
|
|
364
408
|
# Start uvicorn
|
@@ -373,12 +417,13 @@ def serve(db_file: str, sqld_port: int, env_port: int, no_sqld: bool, no_env: bo
|
|
373
417
|
str(env_port),
|
374
418
|
"--log-level",
|
375
419
|
"info",
|
376
|
-
"--reload",
|
377
420
|
]
|
378
421
|
|
379
|
-
|
380
|
-
|
381
|
-
|
422
|
+
if reload_enabled:
|
423
|
+
uvicorn_cmd.append("--reload")
|
424
|
+
# If running from source, add reload directory
|
425
|
+
if os.path.exists("synth_ai"):
|
426
|
+
uvicorn_cmd.extend(["--reload-dir", "synth_ai"])
|
382
427
|
|
383
428
|
proc = subprocess.Popen(uvicorn_cmd, env=env)
|
384
429
|
processes.append(proc)
|
synth_ai/cli/man.py
ADDED
@@ -0,0 +1,103 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
CLI: human-friendly manual for Synth AI commands and options.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import click
|
9
|
+
from rich.console import Console
|
10
|
+
from rich.table import Table
|
11
|
+
from rich.panel import Panel
|
12
|
+
from rich import box
|
13
|
+
|
14
|
+
|
15
|
+
def _commands_table() -> Table:
|
16
|
+
t = Table(title="Commands", box=box.SIMPLE, header_style="bold")
|
17
|
+
t.add_column("Command")
|
18
|
+
t.add_column("Summary")
|
19
|
+
t.add_row(
|
20
|
+
"balance",
|
21
|
+
"Show remaining credit balance (USD) and a compact spend summary for last 24h and 7d.\n"
|
22
|
+
"Options: --base-url, --api-key, --usage",
|
23
|
+
)
|
24
|
+
t.add_row(
|
25
|
+
"traces",
|
26
|
+
"List local trace DBs, trace counts, experiments, and per-system counts.\n"
|
27
|
+
"Options: --root",
|
28
|
+
)
|
29
|
+
t.add_row(
|
30
|
+
"experiments",
|
31
|
+
"Snapshot table of experiments from the local traces DB.\n"
|
32
|
+
"Options: --url, --limit",
|
33
|
+
)
|
34
|
+
t.add_row(
|
35
|
+
"experiment <id>",
|
36
|
+
"Details and sessions for an experiment (accepts partial ID).\n"
|
37
|
+
"Options: --url",
|
38
|
+
)
|
39
|
+
t.add_row(
|
40
|
+
"usage",
|
41
|
+
"Model usage statistics (tokens, cost).\n"
|
42
|
+
"Options: --url, --model",
|
43
|
+
)
|
44
|
+
t.add_row(
|
45
|
+
"status",
|
46
|
+
"DB stats, systems, and environment service health.\n"
|
47
|
+
"Options: --url, --service-url",
|
48
|
+
)
|
49
|
+
t.add_row(
|
50
|
+
"calc '<expr>'",
|
51
|
+
"Evaluate a simple arithmetic expression (e.g., 2*(3+4)).",
|
52
|
+
)
|
53
|
+
t.add_row(
|
54
|
+
"env list | env register | env unregister",
|
55
|
+
"Manage environment registry via the service.\n"
|
56
|
+
"Options vary; see examples.",
|
57
|
+
)
|
58
|
+
return t
|
59
|
+
|
60
|
+
|
61
|
+
def _env_table() -> Table:
|
62
|
+
t = Table(title="Environment Variables", box=box.SIMPLE, header_style="bold")
|
63
|
+
t.add_column("Variable")
|
64
|
+
t.add_column("Used By")
|
65
|
+
t.add_column("Purpose")
|
66
|
+
t.add_row("SYNTH_BACKEND_BASE_URL", "balance", "Backend base URL (preferred) e.g. http://localhost:8000/api/v1")
|
67
|
+
t.add_row("BACKEND_BASE_URL", "balance", "Fallback backend base URL")
|
68
|
+
t.add_row("LOCAL_BACKEND_URL", "balance", "Another fallback backend base URL")
|
69
|
+
t.add_row("SYNTH_BASE_URL", "balance", "Generic base URL (may point to Modal, guarded)")
|
70
|
+
t.add_row("SYNTH_BACKEND_API_KEY", "balance", "Backend API key (preferred)")
|
71
|
+
t.add_row("SYNTH_API_KEY", "balance, env*", "API key used if backend-specific key not set")
|
72
|
+
t.add_row("DEFAULT_DEV_API_KEY", "balance", "Dev fallback key for local testing")
|
73
|
+
t.add_row("SYNTH_TRACES_ROOT", "traces", "Root directory of local trace DBs (default ./synth_ai.db/dbs)")
|
74
|
+
return t
|
75
|
+
|
76
|
+
|
77
|
+
def _examples_table() -> Table:
|
78
|
+
t = Table(title="Examples", box=box.SIMPLE, header_style="bold")
|
79
|
+
t.add_column("Command")
|
80
|
+
t.add_column("Example")
|
81
|
+
t.add_row("Balance (local backend)", "uvx . balance")
|
82
|
+
t.add_row("Balance with URL+key", "uvx . balance --base-url http://localhost:8000 --api-key $SYNTH_API_KEY")
|
83
|
+
t.add_row("Traces (default root)", "uvx . traces")
|
84
|
+
t.add_row("Traces (custom root)", "uvx . traces --root /path/to/dbs")
|
85
|
+
t.add_row("Experiments", "uvx . experiments --limit 20")
|
86
|
+
t.add_row("Experiment detail", "uvx . experiment abcd1234")
|
87
|
+
t.add_row("Usage by model", "uvx . usage --model gpt-4o-mini")
|
88
|
+
t.add_row("Status", "uvx . status")
|
89
|
+
t.add_row("Calc", "uvx . calc '2*(3+4)'")
|
90
|
+
t.add_row("Env list", "uvx . env list --service-url http://localhost:8901")
|
91
|
+
return t
|
92
|
+
|
93
|
+
|
94
|
+
def register(cli):
|
95
|
+
@cli.command(name="man")
|
96
|
+
def man():
|
97
|
+
"""Show Synth AI CLI manual with commands, options, env vars, and examples."""
|
98
|
+
console = Console()
|
99
|
+
console.print(Panel("Synth AI CLI Manual", border_style="cyan"))
|
100
|
+
console.print(_commands_table())
|
101
|
+
console.print(_env_table())
|
102
|
+
console.print(_examples_table())
|
103
|
+
|