synth-ai 0.2.2.dev0__py3-none-any.whl → 0.2.4.dev2__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.
Files changed (115) hide show
  1. synth_ai/cli/__init__.py +66 -0
  2. synth_ai/cli/balance.py +205 -0
  3. synth_ai/cli/calc.py +70 -0
  4. synth_ai/cli/demo.py +74 -0
  5. synth_ai/{cli.py → cli/legacy_root_backup.py} +60 -15
  6. synth_ai/cli/man.py +103 -0
  7. synth_ai/cli/recent.py +126 -0
  8. synth_ai/cli/root.py +184 -0
  9. synth_ai/cli/status.py +126 -0
  10. synth_ai/cli/traces.py +136 -0
  11. synth_ai/cli/watch.py +508 -0
  12. synth_ai/config/base_url.py +53 -0
  13. synth_ai/environments/examples/crafter_classic/agent_demos/analyze_semantic_words_markdown.py +252 -0
  14. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_duckdb_v2_backup.py +413 -0
  15. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/filter_traces_sft_turso.py +760 -0
  16. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/kick_off_ft_synth.py +34 -0
  17. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth.py +1740 -0
  18. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_modal_ft/test_crafter_react_agent_lm_synth_v2_backup.py +1318 -0
  19. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_duckdb_v2_backup.py +386 -0
  20. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/filter_traces_sft_turso.py +580 -0
  21. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v2_backup.py +1352 -0
  22. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/run_rollouts_for_models_and_compare_v3.py +4 -4
  23. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_openai_ft/test_crafter_react_agent_openai_v2_backup.py +2551 -0
  24. synth_ai/environments/examples/crafter_classic/agent_demos/crafter_trace_evaluation.py +1 -1
  25. synth_ai/environments/examples/crafter_classic/agent_demos/example_v3_usage.py +1 -1
  26. synth_ai/environments/examples/crafter_classic/agent_demos/old/traces/session_crafter_episode_16_15227b68-2906-416f-acc4-d6a9b4fa5828_20250725_001154.json +1363 -1
  27. synth_ai/environments/examples/crafter_classic/agent_demos/test_crafter_react_agent.py +3 -3
  28. synth_ai/environments/examples/crafter_classic/environment.py +1 -1
  29. synth_ai/environments/examples/crafter_custom/environment.py +1 -1
  30. synth_ai/environments/examples/enron/dataset/corbt___enron_emails_sample_questions/default/0.0.0/293c9fe8170037e01cc9cf5834e0cd5ef6f1a6bb/dataset_info.json +1 -0
  31. synth_ai/environments/examples/nethack/helpers/achievements.json +64 -0
  32. synth_ai/environments/examples/red/units/test_exploration_strategy.py +1 -1
  33. synth_ai/environments/examples/red/units/test_menu_bug_reproduction.py +5 -5
  34. synth_ai/environments/examples/red/units/test_movement_debug.py +2 -2
  35. synth_ai/environments/examples/red/units/test_retry_movement.py +1 -1
  36. synth_ai/environments/examples/sokoban/engine_helpers/vendored/envs/available_envs.json +122 -0
  37. synth_ai/environments/examples/sokoban/verified_puzzles.json +54987 -0
  38. synth_ai/environments/service/core_routes.py +1 -1
  39. synth_ai/experimental/synth_oss.py +446 -0
  40. synth_ai/learning/core.py +21 -0
  41. synth_ai/learning/gateway.py +4 -0
  42. synth_ai/learning/prompts/gepa.py +0 -0
  43. synth_ai/learning/prompts/mipro.py +8 -0
  44. synth_ai/lm/__init__.py +3 -0
  45. synth_ai/lm/core/main.py +4 -0
  46. synth_ai/lm/core/main_v3.py +238 -122
  47. synth_ai/lm/core/vendor_clients.py +4 -0
  48. synth_ai/lm/provider_support/openai.py +11 -2
  49. synth_ai/lm/vendors/base.py +7 -0
  50. synth_ai/lm/vendors/openai_standard.py +339 -4
  51. synth_ai/lm/vendors/openai_standard_responses.py +243 -0
  52. synth_ai/lm/vendors/synth_client.py +155 -5
  53. synth_ai/lm/warmup.py +54 -17
  54. synth_ai/tracing/__init__.py +18 -0
  55. synth_ai/tracing_v1/__init__.py +29 -14
  56. synth_ai/tracing_v3/__init__.py +2 -2
  57. synth_ai/tracing_v3/abstractions.py +62 -17
  58. synth_ai/tracing_v3/config.py +13 -7
  59. synth_ai/tracing_v3/db_config.py +6 -6
  60. synth_ai/tracing_v3/hooks.py +1 -1
  61. synth_ai/tracing_v3/llm_call_record_helpers.py +350 -0
  62. synth_ai/tracing_v3/lm_call_record_abstractions.py +257 -0
  63. synth_ai/tracing_v3/session_tracer.py +5 -5
  64. synth_ai/tracing_v3/tests/test_concurrent_operations.py +1 -1
  65. synth_ai/tracing_v3/tests/test_llm_call_records.py +672 -0
  66. synth_ai/tracing_v3/tests/test_session_tracer.py +43 -9
  67. synth_ai/tracing_v3/tests/test_turso_manager.py +1 -1
  68. synth_ai/tracing_v3/turso/manager.py +18 -11
  69. synth_ai/tracing_v3/turso/models.py +1 -0
  70. synth_ai/tui/__main__.py +13 -0
  71. synth_ai/tui/dashboard.py +329 -0
  72. synth_ai/v0/tracing/__init__.py +0 -0
  73. synth_ai/{tracing → v0/tracing}/base_client.py +3 -3
  74. synth_ai/{tracing → v0/tracing}/client_manager.py +1 -1
  75. synth_ai/{tracing → v0/tracing}/context.py +1 -1
  76. synth_ai/{tracing → v0/tracing}/decorators.py +11 -11
  77. synth_ai/v0/tracing/events/__init__.py +0 -0
  78. synth_ai/{tracing → v0/tracing}/events/manage.py +4 -4
  79. synth_ai/{tracing → v0/tracing}/events/scope.py +6 -6
  80. synth_ai/{tracing → v0/tracing}/events/store.py +3 -3
  81. synth_ai/{tracing → v0/tracing}/immediate_client.py +6 -6
  82. synth_ai/{tracing → v0/tracing}/log_client_base.py +2 -2
  83. synth_ai/{tracing → v0/tracing}/retry_queue.py +3 -3
  84. synth_ai/{tracing → v0/tracing}/trackers.py +2 -2
  85. synth_ai/{tracing → v0/tracing}/upload.py +4 -4
  86. synth_ai/v0/tracing_v1/__init__.py +16 -0
  87. synth_ai/{tracing_v1 → v0/tracing_v1}/base_client.py +3 -3
  88. synth_ai/{tracing_v1 → v0/tracing_v1}/client_manager.py +1 -1
  89. synth_ai/{tracing_v1 → v0/tracing_v1}/context.py +1 -1
  90. synth_ai/{tracing_v1 → v0/tracing_v1}/decorators.py +11 -11
  91. synth_ai/v0/tracing_v1/events/__init__.py +0 -0
  92. synth_ai/{tracing_v1 → v0/tracing_v1}/events/manage.py +4 -4
  93. synth_ai/{tracing_v1 → v0/tracing_v1}/events/scope.py +6 -6
  94. synth_ai/{tracing_v1 → v0/tracing_v1}/events/store.py +3 -3
  95. synth_ai/{tracing_v1 → v0/tracing_v1}/immediate_client.py +6 -6
  96. synth_ai/{tracing_v1 → v0/tracing_v1}/log_client_base.py +2 -2
  97. synth_ai/{tracing_v1 → v0/tracing_v1}/retry_queue.py +3 -3
  98. synth_ai/{tracing_v1 → v0/tracing_v1}/trackers.py +2 -2
  99. synth_ai/{tracing_v1 → v0/tracing_v1}/upload.py +4 -4
  100. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/METADATA +100 -5
  101. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/RECORD +115 -75
  102. /synth_ai/{tracing/events/__init__.py → compound/cais.py} +0 -0
  103. /synth_ai/{tracing_v1/events/__init__.py → environments/examples/crafter_classic/debug_translation.py} +0 -0
  104. /synth_ai/{tracing → v0/tracing}/abstractions.py +0 -0
  105. /synth_ai/{tracing → v0/tracing}/config.py +0 -0
  106. /synth_ai/{tracing → v0/tracing}/local.py +0 -0
  107. /synth_ai/{tracing → v0/tracing}/utils.py +0 -0
  108. /synth_ai/{tracing_v1 → v0/tracing_v1}/abstractions.py +0 -0
  109. /synth_ai/{tracing_v1 → v0/tracing_v1}/config.py +0 -0
  110. /synth_ai/{tracing_v1 → v0/tracing_v1}/local.py +0 -0
  111. /synth_ai/{tracing_v1 → v0/tracing_v1}/utils.py +0 -0
  112. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/WHEEL +0 -0
  113. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/entry_points.txt +0 -0
  114. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/licenses/LICENSE +0 -0
  115. {synth_ai-0.2.2.dev0.dist-info → synth_ai-0.2.4.dev2.dist-info}/top_level.txt +0 -0
synth_ai/cli/watch.py ADDED
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CLI visualizer: live watch + experiment listings.
4
+
5
+ Placed beside scope.txt for discoverability.
6
+ """
7
+
8
+ import asyncio
9
+ from datetime import datetime
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+ import click
13
+ from rich.console import Console, Group
14
+ from rich.table import Table
15
+ from rich.panel import Panel
16
+ from rich.align import Align
17
+ from rich.live import Live
18
+ from rich import box
19
+ import sys
20
+
21
+
22
+ class _State:
23
+ def __init__(self):
24
+ self.view: str = "experiments" # experiments | experiment | usage | traces | recent
25
+ self.view_arg: Optional[str] = None
26
+ self.limit: int = 20
27
+ self.hours: float = 24.0
28
+ self.last_msg: str = "Type 'help' for commands. 'q' to quit."
29
+ self.error: Optional[str] = None
30
+ # UI state for a visible, blinking cursor in the input field
31
+ self.cursor_on: bool = True
32
+
33
+
34
+ def _short_id(exp_id: str) -> str:
35
+ return exp_id[:8] if exp_id else ""
36
+
37
+
38
+ def _format_currency(value: float) -> str:
39
+ try:
40
+ return f"${value:.4f}"
41
+ except Exception:
42
+ return "$0.0000"
43
+
44
+
45
+ def _format_int(value: Any) -> str:
46
+ try:
47
+ return f"{int(value):,}"
48
+ except Exception:
49
+ return "0"
50
+
51
+
52
+ async def _fetch_experiments(db_url: str):
53
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
54
+
55
+ db = AsyncSQLTraceManager(db_url)
56
+ await db.initialize()
57
+ try:
58
+ df = await db.query_traces(
59
+ """
60
+ SELECT
61
+ e.experiment_id,
62
+ e.name,
63
+ e.description,
64
+ e.created_at,
65
+ COUNT(DISTINCT st.session_id) as num_sessions,
66
+ COUNT(DISTINCT ev.id) as num_events,
67
+ COUNT(DISTINCT m.id) as num_messages,
68
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.cost_usd ELSE 0 END) / 100.0 as total_cost,
69
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.total_tokens ELSE 0 END) as total_tokens
70
+ FROM experiments e
71
+ LEFT JOIN session_traces st ON e.experiment_id = st.experiment_id
72
+ LEFT JOIN events ev ON st.session_id = ev.session_id
73
+ LEFT JOIN messages m ON st.session_id = m.session_id
74
+ GROUP BY e.experiment_id, e.name, e.description, e.created_at
75
+ ORDER BY e.created_at DESC
76
+ """
77
+ )
78
+ return df
79
+ finally:
80
+ await db.close()
81
+
82
+
83
+ def _experiments_table(df, limit: Optional[int] = None) -> Table:
84
+ table = Table(
85
+ title="Synth AI Experiments",
86
+ title_style="bold cyan",
87
+ show_edge=False,
88
+ box=box.SIMPLE,
89
+ header_style="bold",
90
+ pad_edge=False,
91
+ )
92
+ for col in ["ID", "Name", "Sessions", "Events", "Msgs", "Cost", "Tokens", "Created"]:
93
+ table.add_column(col, justify="right" if col in {"Sessions","Events","Msgs","Tokens"} else "left")
94
+
95
+ if df is not None and not df.empty:
96
+ rows = df.itertuples(index=False)
97
+ count = 0
98
+ for row in rows:
99
+ if limit is not None and count >= limit:
100
+ break
101
+ count += 1
102
+ table.add_row(
103
+ _short_id(getattr(row, "experiment_id", "")),
104
+ str(getattr(row, "name", "Unnamed"))[:28],
105
+ _format_int(getattr(row, "num_sessions", 0)),
106
+ _format_int(getattr(row, "num_events", 0)),
107
+ _format_int(getattr(row, "num_messages", 0)),
108
+ _format_currency(float(getattr(row, "total_cost", 0.0) or 0.0)),
109
+ _format_int(getattr(row, "total_tokens", 0)),
110
+ str(getattr(row, "created_at", "")),
111
+ )
112
+ else:
113
+ table.add_row("-", "No experiments found", "-", "-", "-", "-", "-", "-")
114
+
115
+ return table
116
+
117
+
118
+ async def _experiment_detail(db_url: str, experiment_id: str) -> Dict[str, Any]:
119
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
120
+
121
+ db = AsyncSQLTraceManager(db_url)
122
+ await db.initialize()
123
+ try:
124
+ exp_df = await db.query_traces(
125
+ """
126
+ SELECT * FROM experiments WHERE experiment_id LIKE :exp_id
127
+ """,
128
+ {"exp_id": f"{experiment_id}%"},
129
+ )
130
+ if exp_df.empty:
131
+ return {"not_found": True}
132
+
133
+ exp = exp_df.iloc[0]
134
+
135
+ sessions = await db.get_sessions_by_experiment(exp["experiment_id"])
136
+ stats_df = await db.query_traces(
137
+ """
138
+ SELECT
139
+ COUNT(DISTINCT ev.id) as total_events,
140
+ COUNT(DISTINCT m.id) as total_messages,
141
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.cost_usd ELSE 0 END) / 100.0 as total_cost,
142
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.total_tokens ELSE 0 END) as total_tokens
143
+ FROM session_traces st
144
+ LEFT JOIN events ev ON st.session_id = ev.session_id
145
+ LEFT JOIN messages m ON st.session_id = m.session_id
146
+ WHERE st.experiment_id = :exp_id
147
+ """,
148
+ {"exp_id": exp["experiment_id"]},
149
+ )
150
+ stats = stats_df.iloc[0] if not stats_df.empty else None
151
+
152
+ return {
153
+ "experiment": exp,
154
+ "sessions": sessions or [],
155
+ "stats": stats,
156
+ }
157
+ finally:
158
+ await db.close()
159
+
160
+
161
+ def _render_experiment_panel(detail: Dict[str, Any]) -> Panel:
162
+ if detail.get("not_found"):
163
+ return Panel("No experiment found for given ID", title="Experiment", border_style="red")
164
+
165
+ exp = detail["experiment"]
166
+ stats = detail.get("stats")
167
+ lines: List[str] = []
168
+ lines.append(f"[bold]🧪 {exp['name']}[/bold] ([dim]{exp['experiment_id']}[/dim])")
169
+ if exp.get("description"):
170
+ lines.append(exp["description"])
171
+ lines.append("")
172
+ if stats is not None:
173
+ lines.append(f"[bold]Stats[/bold] Events: {_format_int(stats['total_events'])} "
174
+ f"Messages: {_format_int(stats['total_messages'])} "
175
+ f"Cost: {_format_currency(float(stats['total_cost'] or 0.0))} "
176
+ f"Tokens: {_format_int(stats['total_tokens'])}")
177
+ lines.append(f"Created: {exp['created_at']}")
178
+ lines.append("")
179
+ sessions = detail.get("sessions", [])
180
+ if sessions:
181
+ lines.append("[bold]Sessions[/bold]")
182
+ for s in sessions[:25]:
183
+ lines.append(
184
+ f" - {s['session_id']} [dim]{s['created_at']}[/dim] "
185
+ f"steps={s['num_timesteps']} events={s['num_events']} msgs={s['num_messages']}"
186
+ )
187
+ else:
188
+ lines.append("No sessions found for experiment.")
189
+
190
+ body = "\n".join(lines)
191
+ return Panel(body, title="Experiment", border_style="cyan")
192
+
193
+
194
+ def register(cli):
195
+ """Attach commands to the top-level click group."""
196
+
197
+ # Note: The former interactive `watch` command has been removed in favor of
198
+ # one-off commands (e.g., `synth-ai experiments`, `synth-ai recent`).
199
+
200
+ @cli.command()
201
+ @click.option(
202
+ "--url",
203
+ "db_url",
204
+ default="sqlite+aiosqlite:///./synth_ai.db/dbs/default/data",
205
+ help="Database URL",
206
+ )
207
+ @click.option("--limit", default=50, type=int, help="Max rows to display")
208
+ def experiments(db_url: str, limit: int):
209
+ """Print a snapshot table of experiments."""
210
+ console = Console()
211
+
212
+ async def _run():
213
+ df = await _fetch_experiments(db_url)
214
+ table = _experiments_table(df, limit)
215
+ console.print(table)
216
+ console.print(
217
+ "\n[dim]Tip:[/dim] use [cyan]synth-ai experiment <id>[/cyan] for details, "
218
+ "[cyan]synth-ai usage[/cyan] for model usage.", sep="",
219
+ )
220
+
221
+ asyncio.run(_run())
222
+
223
+ @cli.command()
224
+ @click.argument("experiment_id")
225
+ @click.option(
226
+ "--url",
227
+ "db_url",
228
+ default="sqlite+aiosqlite:///./synth_ai.db/dbs/default/data",
229
+ help="Database URL",
230
+ )
231
+ def experiment(experiment_id: str, db_url: str):
232
+ """Show details and sessions for an experiment (accepts partial ID)."""
233
+ console = Console()
234
+
235
+ async def _run():
236
+ detail = await _experiment_detail(db_url, experiment_id)
237
+ panel = _render_experiment_panel(detail)
238
+ console.print(panel)
239
+
240
+ asyncio.run(_run())
241
+
242
+ @cli.command()
243
+ @click.option(
244
+ "--url",
245
+ "db_url",
246
+ default="sqlite+aiosqlite:///./synth_ai.db/dbs/default/data",
247
+ help="Database URL",
248
+ )
249
+ @click.option("--model", "model_name", default=None, help="Filter by model name")
250
+ def usage(db_url: str, model_name: Optional[str]):
251
+ """Show model usage statistics (tokens, cost)."""
252
+ console = Console()
253
+
254
+ async def _run():
255
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
256
+ db = AsyncSQLTraceManager(db_url)
257
+ await db.initialize()
258
+ try:
259
+ df = await db.get_model_usage(model_name=model_name)
260
+ finally:
261
+ await db.close()
262
+
263
+ if df is None or df.empty:
264
+ console.print("[dim]No model usage data found.[/dim]")
265
+ return
266
+
267
+ table = Table(title="Model Usage", header_style="bold", box=box.SIMPLE)
268
+ for col in df.columns:
269
+ table.add_column(str(col))
270
+ for row in df.itertuples(index=False):
271
+ table.add_row(*[str(cell) for cell in row])
272
+ console.print(table)
273
+
274
+ asyncio.run(_run())
275
+
276
+
277
+ async def _build_view(console: Console, db_url: str, state: _State):
278
+ header = f"[bold]Synth AI[/bold] • DB: [dim]{db_url}[/dim] • {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
279
+ help_bar = (
280
+ "[dim]Commands:[/dim] "
281
+ "[cyan]experiments[/cyan], [cyan]experiment <id>[/cyan], [cyan]usage [model][/cyan], "
282
+ "[cyan]traces[/cyan], [cyan]recent [hours][/cyan], [cyan]help[/cyan], [cyan]q[/cyan]"
283
+ )
284
+
285
+ body: Any
286
+ if state.view == "experiments":
287
+ df = await _fetch_experiments(db_url)
288
+ body = _experiments_table(df, state.limit)
289
+ elif state.view == "experiment" and state.view_arg:
290
+ detail = await _experiment_detail(db_url, state.view_arg)
291
+ body = _render_experiment_panel(detail)
292
+ elif state.view == "usage":
293
+ body = await _usage_table(db_url, state.view_arg)
294
+ elif state.view == "traces":
295
+ body = await _traces_table(db_url, state.limit)
296
+ elif state.view == "recent":
297
+ body = await _recent_table(db_url, state.hours, state.limit)
298
+ else:
299
+ body = Panel("Unknown view", border_style="red")
300
+
301
+ footer_lines = []
302
+ if state.error:
303
+ footer_lines.append(f"[red]Error:[/red] {state.error}")
304
+ if state.last_msg:
305
+ footer_lines.append(state.last_msg)
306
+ footer = "\n".join(footer_lines) if footer_lines else help_bar
307
+
308
+ # Render a visible input field with an outline and a blinking cursor so users
309
+ # know where to type commands. This is a visual affordance only; input is
310
+ # still read from stdin in the background.
311
+ cursor_char = "█" if state.cursor_on else " "
312
+ placeholder = "Type a command and press Enter"
313
+ # Show a dim placeholder when idle; show last message above via subtitle
314
+ input_text = f"› [dim]{placeholder}[/dim] {cursor_char}"
315
+ input_panel = Panel(
316
+ input_text,
317
+ box=box.SQUARE,
318
+ border_style="magenta",
319
+ padding=(0, 1),
320
+ title="Command",
321
+ title_align="left",
322
+ )
323
+
324
+ combined = Group(
325
+ Align.left(body),
326
+ Align.left(input_panel),
327
+ )
328
+
329
+ return Panel(
330
+ combined,
331
+ title=header,
332
+ border_style="green",
333
+ subtitle=footer,
334
+ )
335
+
336
+
337
+ async def _handle_command(cmd: str, state: _State):
338
+ if not cmd:
339
+ return
340
+ parts = cmd.split()
341
+ c = parts[0].lower()
342
+ args = parts[1:]
343
+
344
+ if c in {":q", "q", "quit", "exit"}:
345
+ raise KeyboardInterrupt()
346
+ if c in {"help", ":h", "h", "?"}:
347
+ state.last_msg = (
348
+ "experiments | experiment <id> | usage [model] | traces | recent [hours] | q"
349
+ )
350
+ return
351
+ if c in {"experiments", "exp", "e"}:
352
+ state.view = "experiments"
353
+ state.view_arg = None
354
+ state.last_msg = "Showing experiments"
355
+ return
356
+ if c in {"experiment", "x"} and args:
357
+ state.view = "experiment"
358
+ state.view_arg = args[0]
359
+ state.last_msg = f"Experiment {args[0]}"
360
+ return
361
+ if c in {"usage", "u"}:
362
+ state.view = "usage"
363
+ state.view_arg = args[0] if args else None
364
+ state.last_msg = f"Usage {state.view_arg or ''}"
365
+ return
366
+ if c in {"traces", "t"}:
367
+ state.view = "traces"
368
+ state.view_arg = None
369
+ state.last_msg = "Recent sessions"
370
+ return
371
+ if c in {"recent", "r"}:
372
+ hours = _parse_hours(args)
373
+ if hours is not None:
374
+ state.hours = hours
375
+ state.view = "recent"
376
+ state.last_msg = f"Recent {state.hours:g}h"
377
+ return
378
+
379
+ state.last_msg = f"Unknown command: {cmd}"
380
+
381
+
382
+ def _parse_hours(args: List[str]) -> Optional[float]:
383
+ if not args:
384
+ return None
385
+ # Accept formats: "6", "--6", "-6", "6h"
386
+ token = args[0]
387
+ token = token.lstrip("-")
388
+ if token.endswith("h"):
389
+ token = token[:-1]
390
+ try:
391
+ return float(token)
392
+ except ValueError:
393
+ return None
394
+
395
+
396
+ async def _usage_table(db_url: str, model_name: Optional[str]):
397
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
398
+ db = AsyncSQLTraceManager(db_url)
399
+ await db.initialize()
400
+ try:
401
+ df = await db.get_model_usage(model_name=model_name)
402
+ finally:
403
+ await db.close()
404
+
405
+ table = Table(title="Model Usage", header_style="bold", box=box.SIMPLE)
406
+ if df is None or df.empty:
407
+ table.add_column("Info")
408
+ table.add_row("No model usage data found.")
409
+ return table
410
+ for col in df.columns:
411
+ table.add_column(str(col))
412
+ for row in df.itertuples(index=False):
413
+ table.add_row(*[str(cell) for cell in row])
414
+ return table
415
+
416
+
417
+ async def _traces_table(db_url: str, limit: int):
418
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
419
+ db = AsyncSQLTraceManager(db_url)
420
+ await db.initialize()
421
+ try:
422
+ df = await db.query_traces("SELECT * FROM session_summary ORDER BY created_at DESC")
423
+ finally:
424
+ await db.close()
425
+
426
+ table = Table(title="Recent Sessions", box=box.SIMPLE, header_style="bold")
427
+ for col in ["Session", "Experiment", "Events", "Msgs", "Timesteps", "Cost", "Created"]:
428
+ table.add_column(col, justify="right" if col in {"Events","Msgs","Timesteps"} else "left")
429
+
430
+ if df is None or df.empty:
431
+ table.add_row("-", "No sessions found", "-", "-", "-", "-", "-")
432
+ else:
433
+ count = 0
434
+ for _, r in df.iterrows():
435
+ if count >= limit:
436
+ break
437
+ count += 1
438
+ table.add_row(
439
+ str(r.get("session_id", ""))[:10],
440
+ str(r.get("experiment_name", ""))[:24],
441
+ f"{int(r.get('num_events', 0)):,}",
442
+ f"{int(r.get('num_messages', 0)):,}",
443
+ f"{int(r.get('num_timesteps', 0)):,}",
444
+ f"${float(r.get('total_cost_usd', 0.0) or 0.0):.4f}",
445
+ str(r.get("created_at", "")),
446
+ )
447
+ return table
448
+
449
+
450
+ async def _recent_table(db_url: str, hours: float, limit: int):
451
+ # Inline the recent query to avoid cross-module coupling
452
+ from datetime import timedelta
453
+ from synth_ai.tracing_v3.turso.manager import AsyncSQLTraceManager
454
+
455
+ start_time = datetime.now() - timedelta(hours=hours)
456
+ db = AsyncSQLTraceManager(db_url)
457
+ await db.initialize()
458
+ try:
459
+ query = """
460
+ WITH windowed_sessions AS (
461
+ SELECT * FROM session_traces WHERE created_at >= :start_time
462
+ )
463
+ SELECT
464
+ e.experiment_id,
465
+ e.name,
466
+ MIN(ws.created_at) AS window_start,
467
+ MAX(ws.created_at) AS window_end,
468
+ COUNT(DISTINCT ws.session_id) AS runs,
469
+ COUNT(DISTINCT ev.id) AS events,
470
+ COUNT(DISTINCT m.id) AS messages,
471
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.cost_usd ELSE 0 END) / 100.0 AS cost_usd,
472
+ SUM(CASE WHEN ev.event_type = 'cais' THEN ev.total_tokens ELSE 0 END) AS tokens
473
+ FROM windowed_sessions ws
474
+ LEFT JOIN experiments e ON ws.experiment_id = e.experiment_id
475
+ LEFT JOIN events ev ON ws.session_id = ev.session_id
476
+ LEFT JOIN messages m ON ws.session_id = m.session_id
477
+ GROUP BY e.experiment_id, e.name
478
+ ORDER BY window_end DESC
479
+ """
480
+ df = await db.query_traces(query, {"start_time": start_time})
481
+ finally:
482
+ await db.close()
483
+
484
+ table = Table(title=f"Experiments in last {hours:g}h", header_style="bold", box=box.SIMPLE)
485
+ for col in ["Experiment", "Runs", "First", "Last", "Events", "Msgs", "Cost", "Tokens"]:
486
+ table.add_column(col, justify="right" if col in {"Runs","Events","Msgs","Tokens"} else "left")
487
+
488
+ if df is None or df.empty:
489
+ table.add_row("-", "0", "-", "-", "-", "-", "-", "-")
490
+ else:
491
+ count = 0
492
+ for _, r in df.iterrows():
493
+ if count >= limit:
494
+ break
495
+ count += 1
496
+ name = r.get("name") or "Unnamed"
497
+ exp_disp = f"{name[:28]} [dim]({(str(r.get('experiment_id',''))[:8])})[/dim]"
498
+ table.add_row(
499
+ exp_disp,
500
+ f"{int(r.get('runs', 0)):,}",
501
+ str(r.get("window_start", "")),
502
+ str(r.get("window_end", "")),
503
+ f"{int(r.get('events', 0)):,}",
504
+ f"{int(r.get('messages', 0)):,}",
505
+ f"${float(r.get('cost_usd', 0.0) or 0.0):.4f}",
506
+ f"{int(r.get('tokens', 0)):,}",
507
+ )
508
+ return table
@@ -0,0 +1,53 @@
1
+ """
2
+ Base URL resolution for learning-v2 and related backend APIs.
3
+
4
+ Default to production, allow overrides via environment variables:
5
+ - LEARNING_V2_BASE_URL (highest precedence)
6
+ - SYNTH_BASE_URL (legacy)
7
+ - SYNTH_LOCAL_BASE_URL
8
+ - SYNTH_DEV_BASE_URL
9
+ - SYNTH_PROD_BASE_URL (fallback if none provided)
10
+
11
+ Normalization: ensure the returned URL ends with "/api".
12
+ """
13
+
14
+ import os
15
+ from typing import Literal
16
+
17
+
18
+ PROD_BASE_URL_DEFAULT = "https://agent-learning.onrender.com"
19
+
20
+
21
+ def _normalize_base(url: str) -> str:
22
+ url = url.strip()
23
+ if url.endswith("/v1"):
24
+ url = url[:-3]
25
+ url = url.rstrip("/")
26
+ if not url.endswith("/api"):
27
+ url = f"{url}/api"
28
+ return url
29
+
30
+
31
+ def get_learning_v2_base_url(mode: Literal["dev","prod"] = "prod") -> str:
32
+ if mode == "prod":
33
+ prod = os.getenv("SYNTH_PROD_BASE_URL") or PROD_BASE_URL_DEFAULT
34
+ return _normalize_base(prod)
35
+ # Priority order
36
+ env_url = os.getenv("LEARNING_V2_BASE_URL")
37
+ if env_url:
38
+ return _normalize_base(env_url)
39
+
40
+ legacy = os.getenv("SYNTH_BASE_URL")
41
+ if legacy:
42
+ return _normalize_base(legacy)
43
+
44
+ local = os.getenv("SYNTH_LOCAL_BASE_URL")
45
+ if local:
46
+ return _normalize_base(local)
47
+
48
+ dev = os.getenv("SYNTH_DEV_BASE_URL")
49
+ if dev:
50
+ return _normalize_base(dev)
51
+
52
+ raise Exception()
53
+