synth-ai 0.4.1__py3-none-any.whl → 0.4.4__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.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (153) hide show
  1. synth_ai/__init__.py +13 -13
  2. synth_ai/cli/__init__.py +6 -15
  3. synth_ai/cli/commands/eval/__init__.py +6 -15
  4. synth_ai/cli/commands/eval/config.py +338 -0
  5. synth_ai/cli/commands/eval/core.py +236 -1091
  6. synth_ai/cli/commands/eval/runner.py +704 -0
  7. synth_ai/cli/commands/eval/validation.py +44 -117
  8. synth_ai/cli/commands/filter/core.py +7 -7
  9. synth_ai/cli/commands/filter/validation.py +2 -2
  10. synth_ai/cli/commands/smoke/core.py +7 -17
  11. synth_ai/cli/commands/status/__init__.py +1 -64
  12. synth_ai/cli/commands/status/client.py +50 -151
  13. synth_ai/cli/commands/status/config.py +3 -83
  14. synth_ai/cli/commands/status/errors.py +4 -13
  15. synth_ai/cli/commands/status/subcommands/__init__.py +2 -8
  16. synth_ai/cli/commands/status/subcommands/config.py +13 -0
  17. synth_ai/cli/commands/status/subcommands/files.py +18 -63
  18. synth_ai/cli/commands/status/subcommands/jobs.py +28 -311
  19. synth_ai/cli/commands/status/subcommands/models.py +18 -62
  20. synth_ai/cli/commands/status/subcommands/runs.py +16 -63
  21. synth_ai/cli/commands/status/subcommands/session.py +67 -172
  22. synth_ai/cli/commands/status/subcommands/summary.py +24 -32
  23. synth_ai/cli/commands/status/subcommands/utils.py +41 -0
  24. synth_ai/cli/commands/status/utils.py +16 -107
  25. synth_ai/cli/commands/train/__init__.py +18 -20
  26. synth_ai/cli/commands/train/errors.py +3 -3
  27. synth_ai/cli/commands/train/prompt_learning_validation.py +15 -16
  28. synth_ai/cli/commands/train/validation.py +7 -7
  29. synth_ai/cli/commands/train/{judge_schemas.py → verifier_schemas.py} +33 -34
  30. synth_ai/cli/commands/train/verifier_validation.py +235 -0
  31. synth_ai/cli/demo_apps/demo_task_apps/math/config.toml +0 -1
  32. synth_ai/cli/demo_apps/demo_task_apps/math/modal_task_app.py +2 -6
  33. synth_ai/cli/demo_apps/math/config.toml +0 -1
  34. synth_ai/cli/demo_apps/math/modal_task_app.py +2 -6
  35. synth_ai/cli/demo_apps/mipro/task_app.py +25 -47
  36. synth_ai/cli/lib/apps/task_app.py +12 -13
  37. synth_ai/cli/lib/task_app_discovery.py +6 -6
  38. synth_ai/cli/lib/train_cfgs.py +10 -10
  39. synth_ai/cli/task_apps/__init__.py +11 -0
  40. synth_ai/cli/task_apps/commands.py +7 -15
  41. synth_ai/core/env.py +12 -1
  42. synth_ai/core/errors.py +1 -2
  43. synth_ai/core/integrations/cloudflare.py +209 -33
  44. synth_ai/core/tracing_v3/abstractions.py +46 -0
  45. synth_ai/data/__init__.py +3 -30
  46. synth_ai/data/enums.py +1 -20
  47. synth_ai/data/rewards.py +100 -3
  48. synth_ai/products/graph_evolve/__init__.py +1 -2
  49. synth_ai/products/graph_evolve/config.py +16 -16
  50. synth_ai/products/graph_evolve/converters/__init__.py +3 -3
  51. synth_ai/products/graph_evolve/converters/openai_sft.py +7 -7
  52. synth_ai/products/graph_evolve/examples/hotpotqa/config.toml +1 -1
  53. synth_ai/products/graph_gepa/__init__.py +23 -0
  54. synth_ai/products/graph_gepa/converters/__init__.py +19 -0
  55. synth_ai/products/graph_gepa/converters/openai_sft.py +29 -0
  56. synth_ai/sdk/__init__.py +45 -35
  57. synth_ai/sdk/api/eval/__init__.py +33 -0
  58. synth_ai/sdk/api/eval/job.py +732 -0
  59. synth_ai/sdk/api/research_agent/__init__.py +276 -66
  60. synth_ai/sdk/api/train/builders.py +181 -0
  61. synth_ai/sdk/api/train/cli.py +41 -33
  62. synth_ai/sdk/api/train/configs/__init__.py +6 -4
  63. synth_ai/sdk/api/train/configs/prompt_learning.py +127 -33
  64. synth_ai/sdk/api/train/configs/rl.py +264 -16
  65. synth_ai/sdk/api/train/configs/sft.py +165 -1
  66. synth_ai/sdk/api/train/graph_validators.py +12 -12
  67. synth_ai/sdk/api/train/graphgen.py +169 -51
  68. synth_ai/sdk/api/train/graphgen_models.py +95 -45
  69. synth_ai/sdk/api/train/local_api.py +10 -0
  70. synth_ai/sdk/api/train/pollers.py +36 -0
  71. synth_ai/sdk/api/train/prompt_learning.py +390 -60
  72. synth_ai/sdk/api/train/rl.py +41 -5
  73. synth_ai/sdk/api/train/sft.py +2 -0
  74. synth_ai/sdk/api/train/task_app.py +20 -0
  75. synth_ai/sdk/api/train/validators.py +17 -17
  76. synth_ai/sdk/graphs/completions.py +239 -33
  77. synth_ai/sdk/{judging/schemas.py → graphs/verifier_schemas.py} +23 -23
  78. synth_ai/sdk/learning/__init__.py +35 -5
  79. synth_ai/sdk/learning/context_learning_client.py +531 -0
  80. synth_ai/sdk/learning/context_learning_types.py +294 -0
  81. synth_ai/sdk/learning/prompt_learning_client.py +1 -1
  82. synth_ai/sdk/learning/prompt_learning_types.py +2 -1
  83. synth_ai/sdk/learning/rl/__init__.py +0 -4
  84. synth_ai/sdk/learning/rl/contracts.py +0 -4
  85. synth_ai/sdk/localapi/__init__.py +40 -0
  86. synth_ai/sdk/localapi/apps/__init__.py +28 -0
  87. synth_ai/sdk/localapi/client.py +10 -0
  88. synth_ai/sdk/localapi/contracts.py +10 -0
  89. synth_ai/sdk/localapi/helpers.py +519 -0
  90. synth_ai/sdk/localapi/rollouts.py +93 -0
  91. synth_ai/sdk/localapi/server.py +29 -0
  92. synth_ai/sdk/localapi/template.py +49 -0
  93. synth_ai/sdk/streaming/handlers.py +6 -6
  94. synth_ai/sdk/streaming/streamer.py +10 -6
  95. synth_ai/sdk/task/__init__.py +18 -5
  96. synth_ai/sdk/task/apps/__init__.py +37 -1
  97. synth_ai/sdk/task/client.py +9 -1
  98. synth_ai/sdk/task/config.py +6 -11
  99. synth_ai/sdk/task/contracts.py +137 -95
  100. synth_ai/sdk/task/in_process.py +32 -22
  101. synth_ai/sdk/task/in_process_runner.py +9 -4
  102. synth_ai/sdk/task/rubrics/__init__.py +2 -3
  103. synth_ai/sdk/task/rubrics/loaders.py +4 -4
  104. synth_ai/sdk/task/rubrics/strict.py +3 -4
  105. synth_ai/sdk/task/server.py +76 -16
  106. synth_ai/sdk/task/trace_correlation_helpers.py +190 -139
  107. synth_ai/sdk/task/validators.py +34 -49
  108. synth_ai/sdk/training/__init__.py +7 -16
  109. synth_ai/sdk/tunnels/__init__.py +118 -0
  110. synth_ai/sdk/tunnels/cleanup.py +83 -0
  111. synth_ai/sdk/tunnels/ports.py +120 -0
  112. synth_ai/sdk/tunnels/tunneled_api.py +363 -0
  113. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/METADATA +71 -4
  114. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/RECORD +118 -128
  115. synth_ai/cli/commands/baseline/__init__.py +0 -12
  116. synth_ai/cli/commands/baseline/core.py +0 -636
  117. synth_ai/cli/commands/baseline/list.py +0 -94
  118. synth_ai/cli/commands/eval/errors.py +0 -81
  119. synth_ai/cli/commands/status/formatters.py +0 -164
  120. synth_ai/cli/commands/status/subcommands/pricing.py +0 -23
  121. synth_ai/cli/commands/status/subcommands/usage.py +0 -203
  122. synth_ai/cli/commands/train/judge_validation.py +0 -305
  123. synth_ai/cli/usage.py +0 -159
  124. synth_ai/data/specs.py +0 -36
  125. synth_ai/sdk/api/research_agent/cli.py +0 -428
  126. synth_ai/sdk/api/research_agent/config.py +0 -357
  127. synth_ai/sdk/api/research_agent/job.py +0 -717
  128. synth_ai/sdk/baseline/__init__.py +0 -25
  129. synth_ai/sdk/baseline/config.py +0 -209
  130. synth_ai/sdk/baseline/discovery.py +0 -216
  131. synth_ai/sdk/baseline/execution.py +0 -154
  132. synth_ai/sdk/judging/__init__.py +0 -15
  133. synth_ai/sdk/judging/base.py +0 -24
  134. synth_ai/sdk/judging/client.py +0 -191
  135. synth_ai/sdk/judging/types.py +0 -42
  136. synth_ai/sdk/research_agent/__init__.py +0 -34
  137. synth_ai/sdk/research_agent/container_builder.py +0 -328
  138. synth_ai/sdk/research_agent/container_spec.py +0 -198
  139. synth_ai/sdk/research_agent/defaults.py +0 -34
  140. synth_ai/sdk/research_agent/results_collector.py +0 -69
  141. synth_ai/sdk/specs/__init__.py +0 -46
  142. synth_ai/sdk/specs/dataclasses.py +0 -149
  143. synth_ai/sdk/specs/loader.py +0 -144
  144. synth_ai/sdk/specs/serializer.py +0 -199
  145. synth_ai/sdk/specs/validation.py +0 -250
  146. synth_ai/sdk/tracing/__init__.py +0 -39
  147. synth_ai/sdk/usage/__init__.py +0 -37
  148. synth_ai/sdk/usage/client.py +0 -171
  149. synth_ai/sdk/usage/models.py +0 -261
  150. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/WHEEL +0 -0
  151. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/entry_points.txt +0 -0
  152. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/licenses/LICENSE +0 -0
  153. {synth_ai-0.4.1.dist-info → synth_ai-0.4.4.dist-info}/top_level.txt +0 -0
@@ -1,94 +0,0 @@
1
- """List command for baseline discovery."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Optional
7
-
8
- import click
9
-
10
- from synth_ai.sdk.baseline.config import BaselineConfig
11
- from synth_ai.sdk.baseline.discovery import (
12
- BaselineChoice,
13
- discover_baseline_files,
14
- load_baseline_config_from_file,
15
- )
16
-
17
-
18
- @click.command("list")
19
- @click.option(
20
- "--tag",
21
- multiple=True,
22
- help="Filter baselines by tag (can be specified multiple times)",
23
- )
24
- @click.option(
25
- "--metadata",
26
- type=str,
27
- help="Filter by metadata key-value pair (format: key=value)",
28
- )
29
- @click.option(
30
- "--verbose",
31
- is_flag=True,
32
- help="Show detailed information about each baseline",
33
- )
34
- def list_command(tag: tuple[str, ...], metadata: Optional[str], verbose: bool) -> None:
35
- """List all available baseline files."""
36
- search_roots = [Path.cwd()]
37
- choices = discover_baseline_files(search_roots)
38
-
39
- if not choices:
40
- click.echo("No baseline files found.", err=True)
41
- click.echo("Create baseline files in examples/baseline/ or */*_baseline.py")
42
- return
43
-
44
- # Load configs for filtering
45
- configs: list[tuple[BaselineChoice, BaselineConfig]] = []
46
- for choice in choices:
47
- try:
48
- config = load_baseline_config_from_file(choice.baseline_id, choice.path)
49
- configs.append((choice, config))
50
- except Exception as e:
51
- if verbose:
52
- click.echo(f"Warning: Could not load {choice.baseline_id}: {e}", err=True)
53
- continue
54
-
55
- # Apply filters
56
- filtered_configs = configs
57
-
58
- if tag:
59
- tag_set = {t.lower() for t in tag}
60
- filtered_configs = [
61
- (c, config) for c, config in filtered_configs
62
- if any(config.matches_tag(t) for t in tag_set)
63
- ]
64
-
65
- if metadata:
66
- if "=" not in metadata:
67
- raise click.ClickException("--metadata must be in format key=value")
68
- key, value = metadata.split("=", 1)
69
- filtered_configs = [
70
- (c, config) for c, config in filtered_configs
71
- if config.matches_metadata(key.strip(), value.strip())
72
- ]
73
-
74
- if not filtered_configs:
75
- click.echo("No baselines match the specified filters.")
76
- return
77
-
78
- # Display results
79
- click.echo(f"Found {len(filtered_configs)} baseline(s):\n")
80
-
81
- for choice, config in filtered_configs:
82
- click.echo(f" {config.baseline_id}")
83
- click.echo(f" Name: {config.name}")
84
- if config.description:
85
- click.echo(f" Description: {config.description}")
86
- if config.tags:
87
- click.echo(f" Tags: {', '.join(config.tags)}")
88
- click.echo(f" Splits: {', '.join(config.splits.keys())}")
89
- if verbose:
90
- click.echo(f" Path: {choice.path}")
91
- if config.metadata:
92
- click.echo(f" Metadata: {config.metadata}")
93
- click.echo()
94
-
@@ -1,81 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import dataclass
4
-
5
-
6
- class EvalCliError(RuntimeError):
7
- """Base exception for eval CLI failures."""
8
-
9
-
10
- @dataclass(slots=True)
11
- class TomlUnavailableError(EvalCliError):
12
- hint: str | None = None
13
-
14
-
15
- @dataclass(slots=True)
16
- class EvalConfigNotFoundError(EvalCliError):
17
- path: str
18
-
19
-
20
- @dataclass(slots=True)
21
- class EvalConfigParseError(EvalCliError):
22
- path: str
23
- detail: str
24
-
25
-
26
- @dataclass(slots=True)
27
- class MissingEvalTableError(EvalCliError):
28
- """Raised when the eval config lacks an [eval] table."""
29
-
30
-
31
- @dataclass(slots=True)
32
- class InvalidEvalConfigError(EvalCliError):
33
- detail: str
34
-
35
-
36
- @dataclass(slots=True)
37
- class SeedParseError(EvalCliError):
38
- value: str
39
-
40
-
41
- @dataclass(slots=True)
42
- class MetadataFilterFormatError(EvalCliError):
43
- entry: str
44
-
45
-
46
- @dataclass(slots=True)
47
- class TaskInfoUnavailableError(EvalCliError):
48
- """Raised when metadata filters require task info but the task app does not expose it."""
49
-
50
-
51
- @dataclass(slots=True)
52
- class NoSeedsMatchedError(EvalCliError):
53
- hint: str | None = None
54
-
55
-
56
- @dataclass(slots=True)
57
- class MetadataSQLExecutionError(EvalCliError):
58
- query: str
59
- detail: str
60
-
61
-
62
- @dataclass(slots=True)
63
- class MetadataSQLResultError(EvalCliError):
64
- query: str
65
- detail: str
66
-
67
-
68
- __all__ = [
69
- "EvalCliError",
70
- "TomlUnavailableError",
71
- "EvalConfigNotFoundError",
72
- "EvalConfigParseError",
73
- "MissingEvalTableError",
74
- "InvalidEvalConfigError",
75
- "SeedParseError",
76
- "MetadataFilterFormatError",
77
- "TaskInfoUnavailableError",
78
- "NoSeedsMatchedError",
79
- "MetadataSQLExecutionError",
80
- "MetadataSQLResultError",
81
- ]
@@ -1,164 +0,0 @@
1
- """Rich-based formatting helpers for status commands."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from collections.abc import Iterable
7
- from datetime import datetime
8
- from typing import Any
9
-
10
- from rich import box
11
- from rich.console import Console
12
- from rich.panel import Panel
13
- from rich.table import Table
14
- from rich.text import Text
15
-
16
- console = Console()
17
-
18
-
19
- def _format_timestamp(value: Any) -> str:
20
- if value in (None, "", 0):
21
- return ""
22
- if isinstance(value, int | float):
23
- try:
24
- return datetime.fromtimestamp(float(value)).isoformat()
25
- except Exception:
26
- return str(value)
27
- if isinstance(value, str):
28
- try:
29
- return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M:%S")
30
- except Exception:
31
- return value
32
- return str(value)
33
-
34
-
35
- def print_json(data: Any) -> None:
36
- console.print_json(data=data)
37
-
38
-
39
- def jobs_table(jobs: Iterable[dict[str, Any]]) -> Table:
40
- table = Table(title="Training Jobs", box=box.SIMPLE, header_style="bold")
41
- table.add_column("ID", style="cyan", overflow="fold")
42
- table.add_column("Type", style="magenta")
43
- table.add_column("Status")
44
- table.add_column("Created", style="green")
45
- table.add_column("Updated", style="green")
46
- table.add_column("Model", style="yellow", overflow="fold")
47
- for job in jobs:
48
- status = job.get("status", "unknown")
49
- status_color = {
50
- "running": "green",
51
- "queued": "cyan",
52
- "succeeded": "bright_green",
53
- "failed": "red",
54
- "cancelled": "yellow",
55
- }.get(status, "white")
56
- table.add_row(
57
- str(job.get("job_id") or job.get("id", "")),
58
- str(job.get("training_type") or job.get("type", "")),
59
- f"[{status_color}]{status}[/{status_color}]",
60
- _format_timestamp(job.get("created_at")),
61
- _format_timestamp(job.get("updated_at")),
62
- str(job.get("model_id") or job.get("model", "")),
63
- )
64
- return table
65
-
66
-
67
- def job_panel(job: dict[str, Any]) -> Panel:
68
- lines = [f"[bold cyan]Job[/bold cyan] {job.get('job_id') or job.get('id')}"]
69
- if job.get("name"):
70
- lines.append(f"Name: {job['name']}")
71
- lines.append(f"Type: {job.get('training_type', job.get('type', ''))}")
72
- lines.append(f"Status: {job.get('status', 'unknown')}")
73
- if job.get("model_id"):
74
- lines.append(f"Model: {job['model_id']}")
75
- if job.get("base_model"):
76
- lines.append(f"Base Model: {job['base_model']}")
77
- lines.append(f"Created: {_format_timestamp(job.get('created_at'))}")
78
- lines.append(f"Updated: {_format_timestamp(job.get('updated_at'))}")
79
- if config := job.get("config"):
80
- lines.append("")
81
- lines.append(f"[dim]{json.dumps(config, indent=2, sort_keys=True)}[/dim]")
82
- return Panel("\n".join(lines), title="Job Details", border_style="cyan")
83
-
84
-
85
- def runs_table(runs: Iterable[dict[str, Any]]) -> Table:
86
- table = Table(title="Job Runs", box=box.SIMPLE, header_style="bold")
87
- table.add_column("Run #", justify="right")
88
- table.add_column("Engine")
89
- table.add_column("Status")
90
- table.add_column("Created")
91
- table.add_column("Started")
92
- table.add_column("Ended")
93
- table.add_column("Duration", justify="right")
94
- for run in runs:
95
- table.add_row(
96
- str(run.get("run_number") or run.get("id", "")),
97
- str(run.get("engine", "")),
98
- str(run.get("status", "unknown")),
99
- _format_timestamp(run.get("created_at")),
100
- _format_timestamp(run.get("started_at")),
101
- _format_timestamp(run.get("ended_at")),
102
- str(run.get("duration_seconds") or run.get("duration", "")),
103
- )
104
- return table
105
-
106
-
107
- def events_panel(events: Iterable[dict[str, Any]]) -> Panel:
108
- rendered = []
109
- for event in events:
110
- ts = _format_timestamp(event.get("timestamp") or event.get("created_at"))
111
- level = event.get("level") or event.get("severity", "info")
112
- message = event.get("message") or event.get("detail") or ""
113
- rendered.append(f"[dim]{ts}[/dim] [{level}] {message}")
114
- if not rendered:
115
- rendered.append("[dim]No events found.[/dim]")
116
- return Panel("\n".join(rendered), title="Job Events", border_style="green")
117
-
118
-
119
- def metrics_table(metrics: dict[str, Any]) -> Table:
120
- table = Table(title="Job Metrics", box=box.SIMPLE, header_style="bold")
121
- table.add_column("Metric")
122
- table.add_column("Value", justify="right")
123
- for key, value in metrics.items():
124
- if isinstance(value, dict):
125
- table.add_row(key, Text(json.dumps(value), overflow="fold"))
126
- else:
127
- table.add_row(key, str(value))
128
- return table
129
-
130
-
131
- def files_table(files: Iterable[dict[str, Any]]) -> Table:
132
- table = Table(title="Training Files", box=box.SIMPLE, header_style="bold")
133
- table.add_column("ID", overflow="fold")
134
- table.add_column("Purpose")
135
- table.add_column("Size", justify="right")
136
- table.add_column("Created")
137
- table.add_column("Filename", overflow="fold")
138
- for file in files:
139
- table.add_row(
140
- str(file.get("file_id") or file.get("id", "")),
141
- str(file.get("purpose", "")),
142
- str(file.get("bytes", "")),
143
- _format_timestamp(file.get("created_at")),
144
- str(file.get("filename", "")),
145
- )
146
- return table
147
-
148
-
149
- def models_table(models: Iterable[dict[str, Any]]) -> Table:
150
- table = Table(title="Fine-tuned Models", box=box.SIMPLE, header_style="bold")
151
- table.add_column("ID", overflow="fold")
152
- table.add_column("Base")
153
- table.add_column("Created")
154
- table.add_column("Owner")
155
- table.add_column("Status")
156
- for model in models:
157
- table.add_row(
158
- str(model.get("id", model.get("name", ""))),
159
- str(model.get("base_model") or model.get("base", "")),
160
- _format_timestamp(model.get("created_at")),
161
- str(model.get("owner") or model.get("organization", "")),
162
- str(model.get("status", "")),
163
- )
164
- return table
@@ -1,23 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import click
4
- from rich.table import Table
5
-
6
- from synth_ai.core.pricing.model_pricing import MODEL_PRICES # type: ignore[import-untyped]
7
-
8
- from ..formatters import console
9
-
10
-
11
- @click.command("pricing", help="List supported provider/model rates (SDK static table).")
12
- def pricing_command() -> None:
13
- table = Table(title="Supported Models and Rates (USD/token)")
14
- table.add_column("Provider", style="cyan", no_wrap=True)
15
- table.add_column("Model", style="magenta")
16
- table.add_column("Input USD", justify="right")
17
- table.add_column("Output USD", justify="right")
18
- for provider, models in MODEL_PRICES.items():
19
- for model, rates in models.items():
20
- table.add_row(provider, model, f"{rates.input_usd:.9f}", f"{rates.output_usd:.9f}")
21
- console.print(table)
22
-
23
-
@@ -1,203 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import contextlib
4
- from datetime import UTC, datetime, timedelta
5
- from typing import Any
6
-
7
- import click
8
-
9
- from ..client import StatusAPIClient
10
- from ..errors import StatusAPIError
11
- from ..formatters import console
12
- from ..utils import common_options, resolve_context_config
13
-
14
-
15
- def _parse_iso(ts: str | None) -> datetime | None:
16
- if not ts:
17
- return None
18
- try:
19
- # Python 3.11 handles 'YYYY-mm-ddTHH:MM:SS.ssssss+00:00' and '...Z'
20
- if ts.endswith("Z"):
21
- ts = ts.replace("Z", "+00:00")
22
- return datetime.fromisoformat(ts)
23
- except Exception:
24
- return None
25
-
26
-
27
- def _extract_total_usd(events: list[dict[str, Any]]) -> tuple[float, int]:
28
- """Return (usd_total, tokens_total) for an arbitrary job's events.
29
-
30
- Strategy:
31
- - Prefer a consolidated total from any *.completed event with total_usd
32
- - Next, prefer any *.billing.end event with total_usd
33
- - Otherwise, combine usage.recorded's usd_tokens with billing.sandboxes' usd
34
- and sum token counts if present
35
- Works for prompt learning and other job types that follow similar conventions.
36
- """
37
- total_usd = 0.0
38
- token_count = 0
39
-
40
- # Prefer consolidated totals from completion events (any namespace)
41
- for e in reversed(events):
42
- typ = str(e.get("type") or "").lower()
43
- if typ.endswith(".completed"):
44
- data = e.get("data") or {}
45
- try:
46
- total_usd = float(data.get("total_usd") or 0.0)
47
- except Exception:
48
- total_usd = 0.0
49
- # Try common token fields
50
- tc = 0
51
- for k in ("token_count_total", "token_count"):
52
- try:
53
- tc = int(data.get(k) or 0)
54
- if tc:
55
- break
56
- except Exception:
57
- pass
58
- if not tc:
59
- try:
60
- tc = int((data.get("token_count_rollouts") or 0) + (data.get("token_count_mutation") or 0))
61
- except Exception:
62
- tc = 0
63
- token_count = tc
64
- return total_usd, token_count
65
-
66
- # Next, billing.end if present with total_usd
67
- for e in reversed(events):
68
- typ = str(e.get("type") or "").lower()
69
- if typ.endswith("billing.end"):
70
- data = e.get("data") or {}
71
- try:
72
- total_usd = float(data.get("total_usd") or 0.0)
73
- except Exception:
74
- total_usd = 0.0
75
- # token_count may not be present here; fall through to usage tokens calc
76
- break
77
-
78
- # Fallback: combine usage + sandboxes (prompt learning style); generic scan
79
- usd_tokens = 0.0
80
- sandbox_usd = 0.0
81
- # token fields observed across tasks
82
- token_fields = ("token_count_total", "token_count", "tokens_in", "tokens_out",
83
- "token_count_rollouts", "token_count_mutation")
84
- for e in events:
85
- typ = str(e.get("type") or "").lower()
86
- data = e.get("data") or {}
87
- # generic usage-style aggregation
88
- if "usage" in typ or typ.endswith("usage.recorded"):
89
- with contextlib.suppress(Exception):
90
- usd_tokens = float(data.get("usd_tokens") or data.get("usd_estimate") or 0.0)
91
- # accumulate tokens if any
92
- for k in token_fields:
93
- with contextlib.suppress(Exception):
94
- token_count += int(data.get(k) or 0)
95
- # sandbox billing
96
- if typ.endswith("billing.sandboxes"):
97
- with contextlib.suppress(Exception):
98
- sandbox_usd += float(data.get("usd") or 0.0)
99
- return (total_usd or (usd_tokens + sandbox_usd)), token_count
100
-
101
-
102
- @click.command("usage", help="Show recent usage (daily/weekly/monthly) and remaining budget if provided.")
103
- @common_options()
104
- @click.option("--budget-usd", type=float, default=None, help="Optional credit/budget to compute remaining.")
105
- @click.option("--json", "output_json", is_flag=True, help="Emit machine-readable JSON.")
106
- @click.pass_context
107
- def usage_command(
108
- ctx: click.Context,
109
- base_url: str | None,
110
- api_key: str | None,
111
- timeout: float,
112
- budget_usd: float | None,
113
- output_json: bool,
114
- ) -> None:
115
- cfg = resolve_context_config(ctx, base_url=base_url, api_key=api_key, timeout=timeout)
116
- now = datetime.now(UTC)
117
- daily_cutoff = (now - timedelta(days=1)).isoformat()
118
- weekly_cutoff = (now - timedelta(days=7)).isoformat()
119
- monthly_cutoff = (now - timedelta(days=30)).isoformat()
120
-
121
- async def _run() -> tuple[dict[str, float | int], dict[str, float | int], dict[str, float | int]]:
122
- daily = {"usd": 0.0, "tokens": 0, "sandbox_seconds": 0.0}
123
- weekly = {"usd": 0.0, "tokens": 0, "sandbox_seconds": 0.0}
124
- monthly = {"usd": 0.0, "tokens": 0, "sandbox_seconds": 0.0}
125
- async with StatusAPIClient(cfg) as client:
126
- try:
127
- jobs = await client.list_jobs(created_after=weekly_cutoff)
128
- except StatusAPIError as exc:
129
- raise click.ClickException(f"Backend error: {exc}") from exc
130
- for j in jobs or []:
131
- job_id = str(j.get("job_id") or j.get("id") or "")
132
- if not job_id:
133
- continue
134
- try:
135
- events = await client.get_job_events(job_id, since=weekly_cutoff)
136
- except StatusAPIError:
137
- events = []
138
- if not events:
139
- continue
140
- # Use event timestamps for windowing
141
- # Weekly
142
- weekly_ev = [e for e in events if (_parse_iso(e.get("created_at")) or now) >= datetime.fromisoformat(weekly_cutoff)]
143
- w_usd, w_tok = _extract_total_usd(weekly_ev)
144
- weekly["usd"] += w_usd
145
- weekly["tokens"] += w_tok
146
- # sandbox seconds
147
- for e in weekly_ev:
148
- if str(e.get("type") or "").lower().endswith("billing.sandboxes"):
149
- with contextlib.suppress(Exception):
150
- weekly["sandbox_seconds"] += float((e.get("data") or {}).get("seconds") or 0.0)
151
- # Daily
152
- daily_ev = [e for e in events if (_parse_iso(e.get("created_at")) or now) >= datetime.fromisoformat(daily_cutoff)]
153
- d_usd, d_tok = _extract_total_usd(daily_ev)
154
- daily["usd"] += d_usd
155
- daily["tokens"] += d_tok
156
- for e in daily_ev:
157
- if str(e.get("type") or "").lower().endswith("billing.sandboxes"):
158
- with contextlib.suppress(Exception):
159
- daily["sandbox_seconds"] += float((e.get("data") or {}).get("seconds") or 0.0)
160
- # Monthly
161
- monthly_ev = [e for e in events if (_parse_iso(e.get("created_at")) or now) >= datetime.fromisoformat(monthly_cutoff)]
162
- m_usd, m_tok = _extract_total_usd(monthly_ev)
163
- monthly["usd"] += m_usd
164
- monthly["tokens"] += m_tok
165
- for e in monthly_ev:
166
- if str(e.get("type") or "").lower().endswith("billing.sandboxes"):
167
- with contextlib.suppress(Exception):
168
- monthly["sandbox_seconds"] += float((e.get("data") or {}).get("seconds") or 0.0)
169
- return daily, weekly, monthly
170
-
171
- daily, weekly, monthly = __import__("asyncio").run(_run())
172
-
173
- if output_json:
174
- import json as _json
175
- payload: dict[str, Any] = {
176
- "daily": {
177
- "usd": round(float(daily["usd"]), 4),
178
- "tokens": int(daily["tokens"]),
179
- "sandbox_hours": round(float(daily["sandbox_seconds"]) / 3600.0, 4),
180
- },
181
- "weekly": {
182
- "usd": round(float(weekly["usd"]), 4),
183
- "tokens": int(weekly["tokens"]),
184
- "sandbox_hours": round(float(weekly["sandbox_seconds"]) / 3600.0, 4),
185
- },
186
- "monthly": {
187
- "usd": round(float(monthly["usd"]), 4),
188
- "tokens": int(monthly["tokens"]),
189
- "sandbox_hours": round(float(monthly["sandbox_seconds"]) / 3600.0, 4),
190
- },
191
- }
192
- if budget_usd is not None:
193
- payload["remaining_vs_budget"] = round(max(0.0, float(budget_usd) - float(weekly["usd"])), 4)
194
- console.print(_json.dumps(payload))
195
- return
196
-
197
- console.print(f"Daily usage: ${float(daily['usd']):.2f} | tokens {int(daily['tokens'])} | sandbox {float(daily['sandbox_seconds'])/3600.0:.2f}h")
198
- console.print(f"Weekly usage: ${float(weekly['usd']):.2f} | tokens {int(weekly['tokens'])} | sandbox {float(weekly['sandbox_seconds'])/3600.0:.2f}h")
199
- console.print(f"Monthly usage: ${float(monthly['usd']):.2f} | tokens {int(monthly['tokens'])} | sandbox {float(monthly['sandbox_seconds'])/3600.0:.2f}h")
200
- if budget_usd is not None:
201
- remaining = max(0.0, float(budget_usd) - float(weekly["usd"]))
202
- console.print(f"Remaining (vs weekly budget ${float(budget_usd):.2f}): ${remaining:.2f}")
203
-