synth-ai 0.2.16__py3-none-any.whl → 0.2.17__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.
- examples/analyze_semantic_words.sh +2 -2
- examples/blog_posts/pokemon_vl/README.md +98 -0
- examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +25 -0
- examples/blog_posts/pokemon_vl/configs/eval_rl_final.toml +24 -0
- examples/blog_posts/pokemon_vl/configs/filter_high_reward.toml +10 -0
- examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +42 -0
- examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
- examples/blog_posts/warming_up_to_rl/README.md +158 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_groq_qwen32b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_openai_gpt_oss_120b.toml +29 -0
- examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +10 -0
- examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +41 -0
- examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
- examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +65 -107
- examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
- examples/multi_step/configs/verilog_rl_lora.toml +80 -123
- examples/qwen_coder/configs/coder_lora_30b.toml +1 -3
- examples/qwen_coder/configs/coder_lora_4b.toml +4 -1
- examples/qwen_coder/configs/coder_lora_small.toml +1 -3
- examples/qwen_vl/README.md +10 -12
- examples/qwen_vl/SETUP_COMPLETE.md +7 -8
- examples/qwen_vl/VISION_TESTS_COMPLETE.md +2 -3
- examples/qwen_vl/collect_data_via_cli.md +76 -84
- examples/qwen_vl/collect_vision_traces.py +4 -4
- examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +40 -57
- examples/qwen_vl/configs/crafter_vlm_sft_example.toml +1 -2
- examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +20 -37
- examples/qwen_vl/configs/eval_gpt5nano_vision.toml +21 -40
- examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
- examples/qwen_vl/configs/{filter_qwen2vl_sft.toml → filter_qwen3vl_sft.toml} +4 -5
- examples/qwen_vl/configs/filter_vision_sft.toml +2 -3
- examples/qwen_vl/crafter_qwen_vl_agent.py +5 -5
- examples/qwen_vl/run_vision_comparison.sh +6 -7
- examples/rl/README.md +5 -5
- examples/rl/configs/rl_from_base_qwen.toml +26 -1
- examples/rl/configs/rl_from_base_qwen17.toml +5 -2
- examples/rl/task_app/README.md +1 -2
- examples/rl/task_app/math_single_step.py +2 -2
- examples/run_crafter_demo.sh +2 -2
- examples/sft/README.md +1 -1
- examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -1
- examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -1
- examples/swe/task_app/README.md +32 -2
- examples/swe/task_app/grpo_swe_mini.py +4 -0
- examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
- examples/swe/task_app/hosted/envs/mini_swe/environment.py +37 -10
- examples/swe/task_app/hosted/inference/openai_client.py +4 -4
- examples/swe/task_app/morph_backend.py +178 -0
- examples/task_apps/crafter/task_app/README.md +1 -1
- examples/task_apps/crafter/task_app/grpo_crafter.py +66 -3
- examples/task_apps/crafter/task_app/grpo_crafter_task_app.py +1 -1
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +4 -26
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -2
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +17 -49
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +13 -5
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +15 -1
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
- examples/task_apps/math/README.md +1 -2
- examples/task_apps/pokemon_red/README.md +3 -4
- examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +6 -5
- examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +1 -2
- examples/task_apps/pokemon_red/task_app.py +36 -5
- examples/task_apps/sokoban/README.md +2 -3
- examples/task_apps/verilog/eval_groq_qwen32b.toml +12 -14
- examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +1 -1
- examples/vlm/configs/crafter_vlm_gpt4o.toml +4 -1
- examples/warming_up_to_rl/configs/crafter_fft.toml +4 -1
- examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -2
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -2
- examples/warming_up_to_rl/run_local_rollout_traced.py +1 -1
- examples/warming_up_to_rl/task_app/README.md +1 -1
- examples/warming_up_to_rl/task_app/grpo_crafter.py +134 -3
- examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +1 -1
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +3 -27
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -1
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +4 -4
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +6 -3
- examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +5 -0
- synth_ai/api/train/builders.py +9 -3
- synth_ai/api/train/cli.py +125 -10
- synth_ai/api/train/configs/__init__.py +8 -1
- synth_ai/api/train/configs/rl.py +32 -7
- synth_ai/api/train/configs/sft.py +6 -2
- synth_ai/api/train/configs/shared.py +59 -2
- synth_ai/auth/credentials.py +119 -0
- synth_ai/cli/__init__.py +12 -4
- synth_ai/cli/commands/__init__.py +17 -0
- synth_ai/cli/commands/demo/__init__.py +6 -0
- synth_ai/cli/commands/demo/core.py +163 -0
- synth_ai/cli/commands/deploy/__init__.py +23 -0
- synth_ai/cli/commands/deploy/core.py +614 -0
- synth_ai/cli/commands/deploy/errors.py +72 -0
- synth_ai/cli/commands/deploy/validation.py +11 -0
- synth_ai/cli/commands/eval/__init__.py +19 -0
- synth_ai/cli/commands/eval/core.py +1109 -0
- synth_ai/cli/commands/eval/errors.py +81 -0
- synth_ai/cli/commands/eval/validation.py +133 -0
- synth_ai/cli/commands/filter/__init__.py +12 -0
- synth_ai/cli/commands/filter/core.py +388 -0
- synth_ai/cli/commands/filter/errors.py +55 -0
- synth_ai/cli/commands/filter/validation.py +77 -0
- synth_ai/cli/commands/help/__init__.py +177 -0
- synth_ai/cli/commands/help/core.py +73 -0
- synth_ai/cli/commands/status/__init__.py +64 -0
- synth_ai/cli/commands/status/client.py +192 -0
- synth_ai/cli/commands/status/config.py +92 -0
- synth_ai/cli/commands/status/errors.py +20 -0
- synth_ai/cli/commands/status/formatters.py +164 -0
- synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
- synth_ai/cli/commands/status/subcommands/files.py +79 -0
- synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
- synth_ai/cli/commands/status/subcommands/models.py +79 -0
- synth_ai/cli/commands/status/subcommands/runs.py +81 -0
- synth_ai/cli/commands/status/subcommands/summary.py +47 -0
- synth_ai/cli/commands/status/utils.py +114 -0
- synth_ai/cli/commands/train/__init__.py +53 -0
- synth_ai/cli/commands/train/core.py +21 -0
- synth_ai/cli/commands/train/errors.py +117 -0
- synth_ai/cli/commands/train/judge_schemas.py +199 -0
- synth_ai/cli/commands/train/judge_validation.py +304 -0
- synth_ai/cli/commands/train/validation.py +443 -0
- synth_ai/cli/demo.py +2 -162
- synth_ai/cli/deploy/__init__.py +28 -0
- synth_ai/cli/deploy/core.py +5 -0
- synth_ai/cli/deploy/errors.py +23 -0
- synth_ai/cli/deploy/validation.py +5 -0
- synth_ai/cli/eval/__init__.py +36 -0
- synth_ai/cli/eval/core.py +5 -0
- synth_ai/cli/eval/errors.py +31 -0
- synth_ai/cli/eval/validation.py +5 -0
- synth_ai/cli/filter/__init__.py +28 -0
- synth_ai/cli/filter/core.py +5 -0
- synth_ai/cli/filter/errors.py +23 -0
- synth_ai/cli/filter/validation.py +5 -0
- synth_ai/cli/modal_serve/__init__.py +12 -0
- synth_ai/cli/modal_serve/core.py +14 -0
- synth_ai/cli/modal_serve/errors.py +8 -0
- synth_ai/cli/modal_serve/validation.py +11 -0
- synth_ai/cli/serve/__init__.py +12 -0
- synth_ai/cli/serve/core.py +14 -0
- synth_ai/cli/serve/errors.py +8 -0
- synth_ai/cli/serve/validation.py +11 -0
- synth_ai/cli/setup.py +20 -265
- synth_ai/cli/status.py +7 -126
- synth_ai/cli/task_app_deploy.py +1 -10
- synth_ai/cli/task_app_modal_serve.py +4 -9
- synth_ai/cli/task_app_serve.py +4 -11
- synth_ai/cli/task_apps.py +58 -1487
- synth_ai/cli/train/__init__.py +12 -0
- synth_ai/cli/train/core.py +21 -0
- synth_ai/cli/train/errors.py +8 -0
- synth_ai/cli/train/validation.py +24 -0
- synth_ai/cli/train.py +1 -14
- synth_ai/demos/crafter/grpo_crafter_task_app.py +1 -1
- synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
- synth_ai/environments/examples/red/engine.py +33 -12
- synth_ai/environments/examples/red/engine_helpers/reward_components.py +151 -179
- synth_ai/environments/examples/red/environment.py +26 -0
- synth_ai/environments/examples/red/trace_hooks_v3.py +168 -0
- synth_ai/http.py +12 -0
- synth_ai/judge_schemas.py +10 -11
- synth_ai/learning/rl/client.py +3 -1
- synth_ai/streaming/__init__.py +29 -0
- synth_ai/streaming/config.py +94 -0
- synth_ai/streaming/handlers.py +469 -0
- synth_ai/streaming/streamer.py +301 -0
- synth_ai/streaming/types.py +95 -0
- synth_ai/task/validators.py +2 -2
- synth_ai/tracing_v3/migration_helper.py +1 -2
- synth_ai/utils/env.py +25 -18
- synth_ai/utils/http.py +4 -1
- synth_ai/utils/modal.py +2 -2
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/METADATA +8 -3
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/RECORD +184 -109
- examples/qwen_vl/configs/eval_qwen2vl_vision.toml +0 -44
- synth_ai/cli/tui.py +0 -62
- synth_ai/tui/__init__.py +0 -5
- synth_ai/tui/__main__.py +0 -13
- synth_ai/tui/cli/__init__.py +0 -1
- synth_ai/tui/cli/query_experiments.py +0 -164
- synth_ai/tui/cli/query_experiments_v3.py +0 -164
- synth_ai/tui/dashboard.py +0 -911
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.17.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import MutableMapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["validate_filter_options"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_filter_options(options: MutableMapping[str, Any]) -> MutableMapping[str, Any]:
|
|
11
|
+
"""Validate parameters passed to the filter CLI command."""
|
|
12
|
+
# Coerce optional collections to the expected container types and strip blanks
|
|
13
|
+
result: dict[str, Any] = dict(options)
|
|
14
|
+
|
|
15
|
+
def _coerce_list(key: str) -> None:
|
|
16
|
+
value = result.get(key)
|
|
17
|
+
if value is None:
|
|
18
|
+
result[key] = []
|
|
19
|
+
elif isinstance(value, list | tuple | set):
|
|
20
|
+
result[key] = [str(item).strip() for item in value if str(item).strip()]
|
|
21
|
+
else:
|
|
22
|
+
result[key] = [str(value).strip()] if str(value).strip() else []
|
|
23
|
+
|
|
24
|
+
def _coerce_dict(key: str) -> None:
|
|
25
|
+
value = result.get(key)
|
|
26
|
+
if value is None:
|
|
27
|
+
result[key] = {}
|
|
28
|
+
elif isinstance(value, MutableMapping):
|
|
29
|
+
normalized: dict[str, float] = {}
|
|
30
|
+
for k, v in value.items():
|
|
31
|
+
if k is None:
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
number = float(v)
|
|
35
|
+
if math.isnan(number) or math.isinf(number):
|
|
36
|
+
continue
|
|
37
|
+
normalized[str(k).strip()] = number
|
|
38
|
+
except Exception:
|
|
39
|
+
continue
|
|
40
|
+
result[key] = normalized
|
|
41
|
+
else:
|
|
42
|
+
result[key] = {}
|
|
43
|
+
|
|
44
|
+
_coerce_list("splits")
|
|
45
|
+
_coerce_list("task_ids")
|
|
46
|
+
_coerce_list("models")
|
|
47
|
+
_coerce_dict("min_judge_scores")
|
|
48
|
+
_coerce_dict("max_judge_scores")
|
|
49
|
+
|
|
50
|
+
for duration_key in ("min_official_score", "max_official_score"):
|
|
51
|
+
value = result.get(duration_key)
|
|
52
|
+
if value is None or value == "":
|
|
53
|
+
result[duration_key] = None
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
result[duration_key] = float(value)
|
|
57
|
+
except Exception:
|
|
58
|
+
result[duration_key] = None
|
|
59
|
+
|
|
60
|
+
for int_key in ("limit", "offset", "shuffle_seed"):
|
|
61
|
+
value = result.get(int_key)
|
|
62
|
+
if value is None or value == "":
|
|
63
|
+
result[int_key] = None
|
|
64
|
+
else:
|
|
65
|
+
try:
|
|
66
|
+
result[int_key] = int(value)
|
|
67
|
+
except Exception:
|
|
68
|
+
result[int_key] = None
|
|
69
|
+
|
|
70
|
+
shuffle_value = result.get("shuffle")
|
|
71
|
+
if isinstance(shuffle_value, str):
|
|
72
|
+
result["shuffle"] = shuffle_value.strip().lower() in {"1", "true", "yes"}
|
|
73
|
+
else:
|
|
74
|
+
result["shuffle"] = bool(shuffle_value)
|
|
75
|
+
|
|
76
|
+
# Preserve extra keys (e.g., min_created_at) as-is for downstream handling
|
|
77
|
+
return result
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Help content for CLI commands."""
|
|
2
|
+
|
|
3
|
+
DEPLOY_HELP = """
|
|
4
|
+
Deploy a Synth AI task app locally or to Modal.
|
|
5
|
+
|
|
6
|
+
OVERVIEW
|
|
7
|
+
--------
|
|
8
|
+
The deploy command supports two runtimes:
|
|
9
|
+
• modal: Deploy to Modal's cloud platform (default)
|
|
10
|
+
• uvicorn: Run locally with FastAPI/Uvicorn
|
|
11
|
+
|
|
12
|
+
BASIC USAGE
|
|
13
|
+
-----------
|
|
14
|
+
# Deploy to Modal (production)
|
|
15
|
+
uvx synth-ai deploy
|
|
16
|
+
|
|
17
|
+
# Deploy specific task app
|
|
18
|
+
uvx synth-ai deploy my-math-app
|
|
19
|
+
|
|
20
|
+
# Run locally for development
|
|
21
|
+
uvx synth-ai deploy --runtime=uvicorn --port 8001
|
|
22
|
+
|
|
23
|
+
MODAL DEPLOYMENT
|
|
24
|
+
----------------
|
|
25
|
+
Modal deployment requires:
|
|
26
|
+
1. Modal authentication (run: modal token new)
|
|
27
|
+
2. ENVIRONMENT_API_KEY (run: uvx synth-ai setup)
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--modal-mode [deploy|serve] Use 'deploy' for production (default),
|
|
31
|
+
'serve' for ephemeral development
|
|
32
|
+
--name TEXT Override Modal app name
|
|
33
|
+
--dry-run Preview the deploy command without executing
|
|
34
|
+
--env-file PATH Env file(s) to load (can be repeated)
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
# Standard production deployment
|
|
38
|
+
uvx synth-ai deploy --runtime=modal
|
|
39
|
+
|
|
40
|
+
# Deploy with custom name
|
|
41
|
+
uvx synth-ai deploy --runtime=modal --name my-task-app-v2
|
|
42
|
+
|
|
43
|
+
# Preview deployment command
|
|
44
|
+
uvx synth-ai deploy --dry-run
|
|
45
|
+
|
|
46
|
+
# Deploy with custom env file
|
|
47
|
+
uvx synth-ai deploy --env-file .env.production
|
|
48
|
+
|
|
49
|
+
LOCAL DEVELOPMENT
|
|
50
|
+
-----------------
|
|
51
|
+
Run locally with auto-reload and tracing:
|
|
52
|
+
|
|
53
|
+
uvx synth-ai deploy --runtime=uvicorn --port 8001 --reload
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--host TEXT Bind address (default: 0.0.0.0)
|
|
57
|
+
--port INTEGER Port number (prompted if not provided)
|
|
58
|
+
--reload/--no-reload Enable auto-reload on code changes
|
|
59
|
+
--force/--no-force Kill existing process on port
|
|
60
|
+
--trace PATH Enable tracing to directory (default: traces/v3)
|
|
61
|
+
--trace-db PATH SQLite DB for traces
|
|
62
|
+
|
|
63
|
+
Examples:
|
|
64
|
+
# Basic local server
|
|
65
|
+
uvx synth-ai deploy --runtime=uvicorn
|
|
66
|
+
|
|
67
|
+
# Development with auto-reload
|
|
68
|
+
uvx synth-ai deploy --runtime=uvicorn --reload --port 8001
|
|
69
|
+
|
|
70
|
+
# With custom trace directory
|
|
71
|
+
uvx synth-ai deploy --runtime=uvicorn --trace ./my-traces
|
|
72
|
+
|
|
73
|
+
TROUBLESHOOTING
|
|
74
|
+
---------------
|
|
75
|
+
Common issues:
|
|
76
|
+
|
|
77
|
+
1. "ENVIRONMENT_API_KEY is required"
|
|
78
|
+
→ Run: uvx synth-ai setup
|
|
79
|
+
|
|
80
|
+
2. "Modal CLI not found"
|
|
81
|
+
→ Install: pip install modal
|
|
82
|
+
→ Authenticate: modal token new
|
|
83
|
+
|
|
84
|
+
3. "Task app not found"
|
|
85
|
+
→ Check app_id matches your task_app.py configuration
|
|
86
|
+
→ Run: uvx synth-ai task-app list (if available)
|
|
87
|
+
|
|
88
|
+
4. "Port already in use" (uvicorn)
|
|
89
|
+
→ Use --force to kill existing process
|
|
90
|
+
→ Or specify different --port
|
|
91
|
+
|
|
92
|
+
5. "No env file discovered"
|
|
93
|
+
→ Create .env file with required keys
|
|
94
|
+
→ Or pass --env-file explicitly
|
|
95
|
+
|
|
96
|
+
ENVIRONMENT VARIABLES
|
|
97
|
+
---------------------
|
|
98
|
+
SYNTH_API_KEY Your Synth platform API key
|
|
99
|
+
ENVIRONMENT_API_KEY Task environment authentication
|
|
100
|
+
TASK_APP_BASE_URL Base URL for deployed task app
|
|
101
|
+
DEMO_DIR Demo directory path
|
|
102
|
+
SYNTH_DEMO_DIR Alternative demo directory
|
|
103
|
+
|
|
104
|
+
For more information: https://docs.usesynth.ai/deploy
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
SETUP_HELP = """
|
|
108
|
+
Configure Synth AI credentials and environment.
|
|
109
|
+
|
|
110
|
+
OVERVIEW
|
|
111
|
+
--------
|
|
112
|
+
The setup command initializes your Synth AI environment by:
|
|
113
|
+
1. Authenticating with the Synth platform via browser
|
|
114
|
+
2. Saving your API keys to ~/.synth/config
|
|
115
|
+
3. Verifying Modal authentication (for deployments)
|
|
116
|
+
4. Testing connectivity to backend services
|
|
117
|
+
|
|
118
|
+
USAGE
|
|
119
|
+
-----
|
|
120
|
+
uvx synth-ai setup
|
|
121
|
+
|
|
122
|
+
The command will:
|
|
123
|
+
• Open your browser for authentication (or prompt for manual entry)
|
|
124
|
+
• Save SYNTH_API_KEY and ENVIRONMENT_API_KEY
|
|
125
|
+
• Verify Modal is authenticated
|
|
126
|
+
• Test backend connectivity
|
|
127
|
+
|
|
128
|
+
WHAT YOU'LL NEED
|
|
129
|
+
----------------
|
|
130
|
+
• Web browser for authentication
|
|
131
|
+
• Modal account (for deployments): https://modal.com
|
|
132
|
+
• Active internet connection
|
|
133
|
+
|
|
134
|
+
TROUBLESHOOTING
|
|
135
|
+
---------------
|
|
136
|
+
1. "Failed to fetch keys from frontend"
|
|
137
|
+
→ You'll be prompted to enter keys manually
|
|
138
|
+
→ Get keys from: https://www.usesynth.ai/dashboard/settings
|
|
139
|
+
|
|
140
|
+
2. "Modal authentication status: not authenticated"
|
|
141
|
+
→ Run: modal token new
|
|
142
|
+
→ Then re-run: uvx synth-ai setup
|
|
143
|
+
|
|
144
|
+
3. Browser doesn't open
|
|
145
|
+
→ Check your default browser settings
|
|
146
|
+
→ Or enter keys manually when prompted
|
|
147
|
+
|
|
148
|
+
WHERE ARE KEYS STORED?
|
|
149
|
+
----------------------
|
|
150
|
+
Keys are saved to: ~/.synth/config
|
|
151
|
+
|
|
152
|
+
This file is read automatically by all Synth AI commands.
|
|
153
|
+
You can also use .env files in your project directory.
|
|
154
|
+
|
|
155
|
+
NEXT STEPS
|
|
156
|
+
----------
|
|
157
|
+
After setup completes:
|
|
158
|
+
1. Deploy your task app: uvx synth-ai deploy
|
|
159
|
+
2. Start local development: uvx synth-ai deploy --runtime=uvicorn
|
|
160
|
+
3. Run training: uvx synth-ai train
|
|
161
|
+
|
|
162
|
+
For more information: https://docs.usesynth.ai/setup
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
COMMAND_HELP = {
|
|
166
|
+
"deploy": DEPLOY_HELP,
|
|
167
|
+
"setup": SETUP_HELP,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_command_help(command: str) -> str | None:
|
|
172
|
+
"""Get detailed help text for a command."""
|
|
173
|
+
return COMMAND_HELP.get(command)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["DEPLOY_HELP", "SETUP_HELP", "COMMAND_HELP", "get_command_help"]
|
|
177
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Help command implementation."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click.exceptions import Exit
|
|
5
|
+
|
|
6
|
+
from . import COMMAND_HELP, get_command_help
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@click.command("help")
|
|
10
|
+
@click.argument("command_name", type=str, required=False)
|
|
11
|
+
def help_command(command_name: str | None) -> None:
|
|
12
|
+
"""Display detailed help for Synth AI commands.
|
|
13
|
+
|
|
14
|
+
USAGE
|
|
15
|
+
-----
|
|
16
|
+
uvx synth-ai help [COMMAND]
|
|
17
|
+
|
|
18
|
+
EXAMPLES
|
|
19
|
+
--------
|
|
20
|
+
# List available help topics
|
|
21
|
+
uvx synth-ai help
|
|
22
|
+
|
|
23
|
+
# Get detailed help for deploy
|
|
24
|
+
uvx synth-ai help deploy
|
|
25
|
+
|
|
26
|
+
# Get detailed help for setup
|
|
27
|
+
uvx synth-ai help setup
|
|
28
|
+
"""
|
|
29
|
+
if not command_name:
|
|
30
|
+
# Show list of available help topics
|
|
31
|
+
click.echo("Synth AI - Detailed Help")
|
|
32
|
+
click.echo("=" * 50)
|
|
33
|
+
click.echo("\nAvailable help topics:")
|
|
34
|
+
click.echo("")
|
|
35
|
+
|
|
36
|
+
for cmd in sorted(COMMAND_HELP.keys()):
|
|
37
|
+
click.echo(f" • {cmd}")
|
|
38
|
+
|
|
39
|
+
click.echo("\nUsage:")
|
|
40
|
+
click.echo(" uvx synth-ai help [COMMAND]")
|
|
41
|
+
click.echo("")
|
|
42
|
+
click.echo("Examples:")
|
|
43
|
+
click.echo(" uvx synth-ai help deploy")
|
|
44
|
+
click.echo(" uvx synth-ai help setup")
|
|
45
|
+
click.echo("")
|
|
46
|
+
click.echo("You can also use standard --help flags:")
|
|
47
|
+
click.echo(" uvx synth-ai deploy --help")
|
|
48
|
+
click.echo(" uvx synth-ai setup --help")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# Show detailed help for specific command
|
|
52
|
+
help_text = get_command_help(command_name)
|
|
53
|
+
if not help_text:
|
|
54
|
+
click.echo(f"No detailed help available for '{command_name}'", err=True)
|
|
55
|
+
click.echo(f"\nTry: uvx synth-ai {command_name} --help", err=True)
|
|
56
|
+
click.echo("Or: uvx synth-ai help (to see available topics)", err=True)
|
|
57
|
+
raise Exit(1)
|
|
58
|
+
|
|
59
|
+
click.echo(help_text)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_command() -> click.Command:
|
|
63
|
+
"""Get the help command for registration."""
|
|
64
|
+
return help_command
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def register(group: click.Group) -> None:
|
|
68
|
+
"""Register the help command with a Click group."""
|
|
69
|
+
group.add_command(help_command)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["help_command", "get_command", "register"]
|
|
73
|
+
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Status and listing commands for the Synth CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .config import resolve_backend_config
|
|
8
|
+
from .subcommands.files import files_group
|
|
9
|
+
from .subcommands.jobs import jobs_group
|
|
10
|
+
from .subcommands.models import models_group
|
|
11
|
+
from .subcommands.runs import runs_group
|
|
12
|
+
from .subcommands.summary import summary_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _attach_group(cli: click.Group, group: click.Group, name: str) -> None:
|
|
16
|
+
"""Attach the provided Click group to the CLI if not already present."""
|
|
17
|
+
if name in cli.commands:
|
|
18
|
+
return
|
|
19
|
+
cli.add_command(group, name=name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register(cli: click.Group) -> None:
|
|
23
|
+
"""Register all status command groups on the provided CLI root."""
|
|
24
|
+
|
|
25
|
+
@click.group(help="Inspect training jobs, models, files, and job runs.")
|
|
26
|
+
@click.option(
|
|
27
|
+
"--base-url",
|
|
28
|
+
envvar="SYNTH_STATUS_BASE_URL",
|
|
29
|
+
default=None,
|
|
30
|
+
help="Synth backend base URL (defaults to environment configuration).",
|
|
31
|
+
)
|
|
32
|
+
@click.option(
|
|
33
|
+
"--api-key",
|
|
34
|
+
envvar="SYNTH_STATUS_API_KEY",
|
|
35
|
+
default=None,
|
|
36
|
+
help="API key for authenticated requests (falls back to Synth defaults).",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--timeout",
|
|
40
|
+
default=30.0,
|
|
41
|
+
show_default=True,
|
|
42
|
+
type=float,
|
|
43
|
+
help="HTTP request timeout in seconds.",
|
|
44
|
+
)
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def status(ctx: click.Context, base_url: str | None, api_key: str | None, timeout: float) -> None:
|
|
47
|
+
"""Populate shared backend configuration for subcommands."""
|
|
48
|
+
cfg = resolve_backend_config(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
49
|
+
ctx.ensure_object(dict)
|
|
50
|
+
ctx.obj["status_backend_config"] = cfg
|
|
51
|
+
|
|
52
|
+
status.add_command(jobs_group, name="jobs")
|
|
53
|
+
status.add_command(models_group, name="models")
|
|
54
|
+
status.add_command(files_group, name="files")
|
|
55
|
+
status.add_command(runs_group, name="runs")
|
|
56
|
+
status.add_command(summary_command, name="summary")
|
|
57
|
+
|
|
58
|
+
cli.add_command(status, name="status")
|
|
59
|
+
_attach_group(cli, jobs_group, "jobs")
|
|
60
|
+
_attach_group(cli, models_group, "models")
|
|
61
|
+
_attach_group(cli, files_group, "files")
|
|
62
|
+
_attach_group(cli, runs_group, "runs")
|
|
63
|
+
if "status-summary" not in cli.commands:
|
|
64
|
+
cli.add_command(summary_command, name="status-summary")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Async HTTP client for Synth status and listing endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .config import BackendConfig
|
|
10
|
+
from .errors import StatusAPIError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatusAPIClient:
|
|
14
|
+
"""Thin wrapper around httpx.AsyncClient with convenience methods."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, config: BackendConfig) -> None:
|
|
17
|
+
self._config = config
|
|
18
|
+
timeout = httpx.Timeout(config.timeout)
|
|
19
|
+
self._client = httpx.AsyncClient(
|
|
20
|
+
base_url=config.base_url,
|
|
21
|
+
headers=config.headers,
|
|
22
|
+
timeout=timeout,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
async def __aenter__(self) -> StatusAPIClient:
|
|
26
|
+
await self._client.__aenter__()
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
30
|
+
await self._client.__aexit__(*args)
|
|
31
|
+
|
|
32
|
+
async def close(self) -> None:
|
|
33
|
+
await self._client.aclose()
|
|
34
|
+
|
|
35
|
+
# Jobs -----------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
async def list_jobs(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
status: str | None = None,
|
|
41
|
+
job_type: str | None = None,
|
|
42
|
+
created_after: str | None = None,
|
|
43
|
+
limit: int | None = None,
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
params: dict[str, Any] = {}
|
|
46
|
+
if status:
|
|
47
|
+
params["status"] = status
|
|
48
|
+
if job_type:
|
|
49
|
+
params["type"] = job_type
|
|
50
|
+
if created_after:
|
|
51
|
+
params["created_after"] = created_after
|
|
52
|
+
if limit:
|
|
53
|
+
params["limit"] = limit
|
|
54
|
+
resp = await self._client.get("/learning/jobs", params=params)
|
|
55
|
+
return self._json_list(resp, key="jobs")
|
|
56
|
+
|
|
57
|
+
async def get_job(self, job_id: str) -> dict[str, Any]:
|
|
58
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}")
|
|
59
|
+
return self._json(resp)
|
|
60
|
+
|
|
61
|
+
async def get_job_status(self, job_id: str) -> dict[str, Any]:
|
|
62
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}/status")
|
|
63
|
+
return self._json(resp)
|
|
64
|
+
|
|
65
|
+
async def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
66
|
+
resp = await self._client.post(f"/learning/jobs/{job_id}/cancel")
|
|
67
|
+
return self._json(resp)
|
|
68
|
+
|
|
69
|
+
async def get_job_config(self, job_id: str) -> dict[str, Any]:
|
|
70
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}/config")
|
|
71
|
+
return self._json(resp)
|
|
72
|
+
|
|
73
|
+
async def get_job_metrics(self, job_id: str) -> dict[str, Any]:
|
|
74
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}/metrics")
|
|
75
|
+
return self._json(resp)
|
|
76
|
+
|
|
77
|
+
async def get_job_timeline(self, job_id: str) -> list[dict[str, Any]]:
|
|
78
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}/timeline")
|
|
79
|
+
return self._json_list(resp, key="timeline")
|
|
80
|
+
|
|
81
|
+
async def list_job_runs(self, job_id: str) -> list[dict[str, Any]]:
|
|
82
|
+
resp = await self._client.get(f"/jobs/{job_id}/runs")
|
|
83
|
+
return self._json_list(resp, key="runs")
|
|
84
|
+
|
|
85
|
+
async def get_job_events(
|
|
86
|
+
self,
|
|
87
|
+
job_id: str,
|
|
88
|
+
*,
|
|
89
|
+
since: str | None = None,
|
|
90
|
+
limit: int | None = None,
|
|
91
|
+
after: str | None = None,
|
|
92
|
+
run_id: str | None = None,
|
|
93
|
+
) -> list[dict[str, Any]]:
|
|
94
|
+
params: dict[str, Any] = {}
|
|
95
|
+
if since:
|
|
96
|
+
params["since"] = since
|
|
97
|
+
if limit:
|
|
98
|
+
params["limit"] = limit
|
|
99
|
+
if after:
|
|
100
|
+
params["after"] = after
|
|
101
|
+
if run_id:
|
|
102
|
+
params["run"] = run_id
|
|
103
|
+
resp = await self._client.get(f"/learning/jobs/{job_id}/events", params=params)
|
|
104
|
+
return self._json_list(resp, key="events")
|
|
105
|
+
|
|
106
|
+
# Files ----------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async def list_files(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
purpose: str | None = None,
|
|
112
|
+
limit: int | None = None,
|
|
113
|
+
) -> list[dict[str, Any]]:
|
|
114
|
+
params: dict[str, Any] = {}
|
|
115
|
+
if purpose:
|
|
116
|
+
params["purpose"] = purpose
|
|
117
|
+
if limit:
|
|
118
|
+
params["limit"] = limit
|
|
119
|
+
resp = await self._client.get("/files", params=params)
|
|
120
|
+
data = self._json(resp)
|
|
121
|
+
if isinstance(data, dict):
|
|
122
|
+
for key in ("files", "data", "items"):
|
|
123
|
+
if isinstance(data.get(key), list):
|
|
124
|
+
return list(data[key])
|
|
125
|
+
if isinstance(data, list):
|
|
126
|
+
return list(data)
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
async def get_file(self, file_id: str) -> dict[str, Any]:
|
|
130
|
+
resp = await self._client.get(f"/files/{file_id}")
|
|
131
|
+
return self._json(resp)
|
|
132
|
+
|
|
133
|
+
# Models ---------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async def list_models(
|
|
136
|
+
self,
|
|
137
|
+
*,
|
|
138
|
+
limit: int | None = None,
|
|
139
|
+
model_type: str | None = None,
|
|
140
|
+
) -> list[dict[str, Any]]:
|
|
141
|
+
params: dict[str, Any] = {}
|
|
142
|
+
if limit:
|
|
143
|
+
params["limit"] = limit
|
|
144
|
+
endpoint = "/learning/models/rl" if model_type == "rl" else "/learning/models"
|
|
145
|
+
resp = await self._client.get(endpoint, params=params)
|
|
146
|
+
return self._json_list(resp, key="models")
|
|
147
|
+
|
|
148
|
+
async def get_model(self, model_id: str) -> dict[str, Any]:
|
|
149
|
+
resp = await self._client.get(f"/learning/models/{model_id}")
|
|
150
|
+
return self._json(resp)
|
|
151
|
+
|
|
152
|
+
# Helpers --------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
def _json(self, response: httpx.Response) -> dict[str, Any]:
|
|
155
|
+
try:
|
|
156
|
+
response.raise_for_status()
|
|
157
|
+
except httpx.HTTPStatusError as exc:
|
|
158
|
+
detail = self._extract_detail(exc.response)
|
|
159
|
+
raise StatusAPIError(detail, exc.response.status_code if exc.response else None) from exc
|
|
160
|
+
try:
|
|
161
|
+
data = response.json()
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise StatusAPIError("Backend response was not valid JSON") from exc
|
|
164
|
+
if isinstance(data, dict):
|
|
165
|
+
return data
|
|
166
|
+
return {"data": data}
|
|
167
|
+
|
|
168
|
+
def _json_list(self, response: httpx.Response, *, key: str | None = None) -> list[dict[str, Any]]:
|
|
169
|
+
payload = self._json(response)
|
|
170
|
+
if key and isinstance(payload.get(key), list):
|
|
171
|
+
return list(payload[key])
|
|
172
|
+
if isinstance(payload.get("data"), list):
|
|
173
|
+
return list(payload["data"])
|
|
174
|
+
if isinstance(payload.get("results"), list):
|
|
175
|
+
return list(payload["results"])
|
|
176
|
+
if isinstance(payload, list):
|
|
177
|
+
return list(payload)
|
|
178
|
+
return []
|
|
179
|
+
|
|
180
|
+
@staticmethod
|
|
181
|
+
def _extract_detail(response: httpx.Response | None) -> str:
|
|
182
|
+
if response is None:
|
|
183
|
+
return "Backend request failed"
|
|
184
|
+
try:
|
|
185
|
+
data = response.json()
|
|
186
|
+
if isinstance(data, dict):
|
|
187
|
+
for key in ("detail", "message", "error"):
|
|
188
|
+
if data.get(key):
|
|
189
|
+
return str(data[key])
|
|
190
|
+
return response.text
|
|
191
|
+
except ValueError:
|
|
192
|
+
return response.text
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Configuration utilities for the status command suite.
|
|
2
|
+
|
|
3
|
+
Provides helpers to resolve backend URLs, API keys, and request timeouts
|
|
4
|
+
from CLI options and environment variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import importlib
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load_backend_helpers() -> tuple[str, Callable[[], tuple[str, str]] | None]:
|
|
18
|
+
"""Attempt to load shared backend helpers from synth_ai.config.base_url."""
|
|
19
|
+
try:
|
|
20
|
+
module = importlib.import_module("synth_ai.config.base_url")
|
|
21
|
+
except Exception:
|
|
22
|
+
return "https://agent-learning.onrender.com", None
|
|
23
|
+
|
|
24
|
+
default = getattr(module, "PROD_BASE_URL_DEFAULT", "https://agent-learning.onrender.com")
|
|
25
|
+
getter = getattr(module, "get_backend_from_env", None)
|
|
26
|
+
return str(default), getter if callable(getter) else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
PROD_BASE_URL_DEFAULT, _GET_BACKEND_FROM_ENV = _load_backend_helpers()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_base_url(raw: str) -> str:
|
|
33
|
+
"""Ensure the configured base URL includes the /api/v1 prefix."""
|
|
34
|
+
base = raw.rstrip("/") if raw else ""
|
|
35
|
+
if not base:
|
|
36
|
+
return raw
|
|
37
|
+
if base.endswith("/api") or base.endswith("/api/v1") or "/api/" in base:
|
|
38
|
+
return base
|
|
39
|
+
return f"{base}/api/v1"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _default_base_url() -> str:
|
|
43
|
+
"""Compute the default backend base URL using env vars or helper module."""
|
|
44
|
+
for var in ("SYNTH_BACKEND_BASE_URL", "BACKEND_BASE_URL", "SYNTH_BASE_URL"):
|
|
45
|
+
val = os.getenv(var)
|
|
46
|
+
if val:
|
|
47
|
+
return _normalize_base_url(val)
|
|
48
|
+
if _GET_BACKEND_FROM_ENV:
|
|
49
|
+
try:
|
|
50
|
+
base, _ = _GET_BACKEND_FROM_ENV()
|
|
51
|
+
return _normalize_base_url(base)
|
|
52
|
+
except Exception:
|
|
53
|
+
pass
|
|
54
|
+
return _normalize_base_url(PROD_BASE_URL_DEFAULT)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_api_key(cli_key: str | None) -> tuple[str | None, str | None]:
|
|
58
|
+
"""Resolve the API key from CLI input or known environment variables."""
|
|
59
|
+
if cli_key:
|
|
60
|
+
return cli_key, "--api-key"
|
|
61
|
+
for var in ("SYNTH_BACKEND_API_KEY", "SYNTH_API_KEY", "DEFAULT_DEV_API_KEY"):
|
|
62
|
+
val = os.getenv(var)
|
|
63
|
+
if val:
|
|
64
|
+
return val, var
|
|
65
|
+
return None, None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass()
|
|
69
|
+
class BackendConfig:
|
|
70
|
+
"""Configuration bundle shared across status commands."""
|
|
71
|
+
|
|
72
|
+
base_url: str
|
|
73
|
+
api_key: str | None
|
|
74
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def headers(self) -> dict[str, str]:
|
|
78
|
+
if not self.api_key:
|
|
79
|
+
return {}
|
|
80
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_backend_config(
|
|
84
|
+
*,
|
|
85
|
+
base_url: str | None,
|
|
86
|
+
api_key: str | None,
|
|
87
|
+
timeout: float | None = None,
|
|
88
|
+
) -> BackendConfig:
|
|
89
|
+
"""Resolve the backend configuration from CLI options/environment."""
|
|
90
|
+
resolved_url = _normalize_base_url(base_url) if base_url else _default_base_url()
|
|
91
|
+
key, _ = _resolve_api_key(api_key)
|
|
92
|
+
return BackendConfig(base_url=resolved_url, api_key=key, timeout=timeout or DEFAULT_TIMEOUT)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Custom error hierarchy for status CLI commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class StatusAPIError(RuntimeError):
|
|
10
|
+
"""Raised when the backend returns a non-success response."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StatusCLIError(RuntimeError):
|
|
18
|
+
"""Raised for client-side validation errors."""
|
|
19
|
+
|
|
20
|
+
pass
|