synth-ai 0.2.14__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/README.md +1 -0
- 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/SFT_README.md +147 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +73 -115
- 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/crafter_sft_qwen30b_lora.toml +62 -0
- examples/multi_step/configs/verilog_rl_lora.toml +80 -123
- examples/multi_step/convert_traces_to_sft.py +84 -0
- examples/multi_step/run_sft_qwen30b.sh +45 -0
- examples/qwen_coder/configs/coder_lora_30b.toml +1 -2
- examples/qwen_coder/configs/coder_lora_4b.toml +5 -1
- examples/qwen_coder/configs/coder_lora_small.toml +1 -2
- examples/qwen_vl/BUGS_AND_FIXES.md +232 -0
- examples/qwen_vl/IMAGE_VALIDATION_COMPLETE.md +271 -0
- examples/qwen_vl/IMAGE_VALIDATION_SUMMARY.md +260 -0
- examples/qwen_vl/INFERENCE_SFT_TESTS.md +412 -0
- examples/qwen_vl/NEXT_STEPS_2B.md +325 -0
- examples/qwen_vl/QUICKSTART.md +327 -0
- examples/qwen_vl/QUICKSTART_RL_VISION.md +110 -0
- examples/qwen_vl/README.md +152 -0
- examples/qwen_vl/RL_VISION_COMPLETE.md +475 -0
- examples/qwen_vl/RL_VISION_TESTING.md +333 -0
- examples/qwen_vl/SDK_VISION_INTEGRATION.md +328 -0
- examples/qwen_vl/SETUP_COMPLETE.md +274 -0
- examples/qwen_vl/VISION_TESTS_COMPLETE.md +489 -0
- examples/qwen_vl/VLM_PIPELINE_COMPLETE.md +242 -0
- examples/qwen_vl/__init__.py +2 -0
- examples/qwen_vl/collect_data_via_cli.md +415 -0
- examples/qwen_vl/collect_vision_traces.py +368 -0
- examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +110 -0
- examples/qwen_vl/configs/crafter_vlm_sft_example.toml +59 -0
- examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +26 -0
- examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
- examples/qwen_vl/configs/eval_gpt5nano_vision.toml +26 -0
- examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
- examples/qwen_vl/configs/filter_qwen3vl_sft.toml +49 -0
- examples/qwen_vl/configs/filter_vision_sft.toml +52 -0
- examples/qwen_vl/configs/filter_vision_test.toml +8 -0
- examples/qwen_vl/configs/sft_qwen3_vl_2b_test.toml +54 -0
- examples/qwen_vl/crafter_gpt5nano_agent.py +308 -0
- examples/qwen_vl/crafter_qwen_vl_agent.py +300 -0
- examples/qwen_vl/run_vision_comparison.sh +61 -0
- examples/qwen_vl/run_vision_sft_pipeline.sh +175 -0
- examples/qwen_vl/test_image_validation.py +201 -0
- examples/qwen_vl/test_sft_vision_data.py +110 -0
- examples/rl/README.md +6 -6
- examples/rl/configs/eval_base_qwen.toml +17 -0
- examples/rl/configs/eval_rl_qwen.toml +13 -0
- examples/rl/configs/rl_from_base_qwen.toml +62 -0
- examples/rl/configs/rl_from_base_qwen17.toml +79 -0
- examples/rl/configs/rl_from_ft_qwen.toml +37 -0
- examples/rl/run_eval.py +436 -0
- examples/rl/run_rl_and_save.py +111 -0
- examples/rl/task_app/README.md +21 -0
- examples/rl/task_app/math_single_step.py +990 -0
- examples/rl/task_app/math_task_app.py +111 -0
- examples/run_crafter_demo.sh +2 -2
- examples/sft/README.md +6 -6
- examples/sft/configs/crafter_fft_qwen0p6b.toml +7 -2
- examples/sft/configs/crafter_lora_qwen0p6b.toml +7 -3
- examples/sft/evaluate.py +2 -4
- examples/sft/export_dataset.py +7 -4
- examples/swe/task_app/README.md +33 -3
- examples/swe/task_app/grpo_swe_mini.py +4 -1
- examples/swe/task_app/grpo_swe_mini_task_app.py +0 -12
- examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
- examples/swe/task_app/hosted/envs/mini_swe/environment.py +50 -23
- examples/swe/task_app/hosted/inference/openai_client.py +4 -4
- examples/swe/task_app/hosted/policy_routes.py +0 -2
- examples/swe/task_app/hosted/rollout.py +0 -8
- 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 +70 -10
- 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 +63 -27
- 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 +48 -50
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +75 -36
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +31 -15
- examples/task_apps/enron/__init__.py +1 -0
- 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/README.md +3 -3
- examples/vlm/configs/crafter_vlm_gpt4o.toml +5 -0
- examples/vlm/crafter_openai_vlm_agent.py +3 -5
- examples/vlm/filter_image_rows.py +1 -1
- examples/vlm/run_crafter_vlm_benchmark.py +2 -2
- examples/warming_up_to_rl/_utils.py +92 -0
- examples/warming_up_to_rl/analyze_trace_db.py +1 -1
- examples/warming_up_to_rl/configs/crafter_fft.toml +5 -0
- examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +2 -0
- examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +2 -0
- examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +2 -1
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -1
- examples/warming_up_to_rl/configs/rl_from_ft.toml +2 -0
- examples/warming_up_to_rl/export_trace_sft.py +174 -60
- examples/warming_up_to_rl/readme.md +63 -132
- examples/warming_up_to_rl/run_fft_and_save.py +1 -1
- examples/warming_up_to_rl/run_local_rollout_traced.py +1 -1
- examples/warming_up_to_rl/run_rl_and_save.py +1 -1
- examples/warming_up_to_rl/task_app/README.md +42 -0
- examples/warming_up_to_rl/task_app/grpo_crafter.py +827 -0
- examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +135 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +143 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1226 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +522 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +454 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +108 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +204 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +618 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +100 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +1084 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +195 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1861 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +211 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +161 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +137 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +62 -0
- 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/__init__.py +44 -30
- synth_ai/_utils/__init__.py +47 -0
- synth_ai/_utils/base_url.py +10 -0
- synth_ai/_utils/http.py +10 -0
- synth_ai/_utils/prompts.py +10 -0
- synth_ai/_utils/task_app_state.py +12 -0
- synth_ai/_utils/user_config.py +10 -0
- synth_ai/api/models/supported.py +144 -7
- synth_ai/api/train/__init__.py +13 -1
- synth_ai/api/train/builders.py +9 -3
- synth_ai/api/train/cli.py +155 -17
- synth_ai/api/train/config_finder.py +18 -11
- 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/api/train/env_resolver.py +13 -10
- synth_ai/auth/credentials.py +119 -0
- synth_ai/cli/__init__.py +61 -69
- synth_ai/cli/_modal_wrapper.py +7 -5
- synth_ai/cli/_typer_patch.py +0 -2
- synth_ai/cli/_validate_task_app.py +22 -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/legacy_root_backup.py +3 -1
- synth_ai/cli/lib/__init__.py +10 -0
- synth_ai/cli/lib/task_app_discovery.py +7 -0
- synth_ai/cli/lib/task_app_env.py +518 -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/recent.py +2 -1
- 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 +21 -0
- synth_ai/cli/status.py +7 -126
- synth_ai/cli/task_app_deploy.py +7 -0
- synth_ai/cli/task_app_list.py +25 -0
- synth_ai/cli/task_app_modal_serve.py +11 -0
- synth_ai/cli/task_app_serve.py +11 -0
- synth_ai/cli/task_apps.py +110 -1499
- synth_ai/cli/traces.py +1 -1
- 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 +5 -0
- synth_ai/cli/turso.py +1 -1
- synth_ai/cli/watch.py +1 -1
- synth_ai/demos/__init__.py +10 -0
- synth_ai/demos/core/__init__.py +28 -1
- synth_ai/demos/crafter/__init__.py +1 -0
- synth_ai/demos/crafter/crafter_fft_4b.toml +55 -0
- synth_ai/demos/crafter/grpo_crafter_task_app.py +185 -0
- synth_ai/demos/crafter/rl_from_base_qwen4b.toml +74 -0
- synth_ai/demos/demo_registry.py +176 -0
- synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
- synth_ai/demos/math/__init__.py +1 -0
- synth_ai/demos/math/_common.py +16 -0
- synth_ai/demos/math/app.py +38 -0
- synth_ai/demos/math/config.toml +76 -0
- synth_ai/demos/math/deploy_modal.py +54 -0
- synth_ai/demos/math/modal_task_app.py +702 -0
- synth_ai/demos/math/task_app_entry.py +51 -0
- synth_ai/environments/environment/core.py +7 -1
- synth_ai/environments/examples/bandit/engine.py +0 -1
- synth_ai/environments/examples/bandit/environment.py +0 -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/environments/examples/wordle/environment.py +0 -1
- synth_ai/evals/base.py +16 -5
- synth_ai/evals/client.py +1 -1
- synth_ai/http.py +8 -22
- synth_ai/inference/client.py +1 -1
- synth_ai/judge_schemas.py +4 -5
- synth_ai/learning/client.py +1 -1
- synth_ai/learning/health.py +1 -1
- synth_ai/learning/jobs.py +1 -1
- synth_ai/learning/rl/client.py +4 -2
- synth_ai/learning/rl/env_keys.py +1 -1
- synth_ai/learning/rl/secrets.py +1 -1
- synth_ai/learning/sft/client.py +1 -1
- synth_ai/learning/sft/data.py +407 -4
- synth_ai/learning/validators.py +4 -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/apps/__init__.py +4 -2
- synth_ai/task/config.py +6 -4
- synth_ai/task/rubrics/__init__.py +1 -2
- synth_ai/task/rubrics/loaders.py +14 -10
- synth_ai/task/rubrics.py +219 -0
- synth_ai/task/trace_correlation_helpers.py +24 -11
- synth_ai/task/tracing_utils.py +14 -3
- synth_ai/task/validators.py +0 -1
- synth_ai/tracing_v3/abstractions.py +3 -3
- synth_ai/tracing_v3/config.py +15 -13
- synth_ai/tracing_v3/constants.py +21 -0
- synth_ai/tracing_v3/db_config.py +3 -1
- synth_ai/tracing_v3/decorators.py +10 -7
- synth_ai/tracing_v3/llm_call_record_helpers.py +5 -5
- synth_ai/tracing_v3/migration_helper.py +1 -2
- synth_ai/tracing_v3/session_tracer.py +7 -7
- synth_ai/tracing_v3/storage/base.py +29 -29
- synth_ai/tracing_v3/storage/config.py +3 -3
- synth_ai/tracing_v3/turso/daemon.py +8 -9
- synth_ai/tracing_v3/turso/native_manager.py +80 -72
- synth_ai/tracing_v3/utils.py +2 -2
- synth_ai/utils/__init__.py +101 -0
- synth_ai/utils/base_url.py +94 -0
- synth_ai/utils/cli.py +131 -0
- synth_ai/utils/env.py +294 -0
- synth_ai/utils/http.py +172 -0
- synth_ai/utils/modal.py +308 -0
- synth_ai/utils/process.py +212 -0
- synth_ai/utils/prompts.py +39 -0
- synth_ai/utils/sqld.py +122 -0
- synth_ai/utils/task_app_discovery.py +882 -0
- synth_ai/utils/task_app_env.py +186 -0
- synth_ai/utils/task_app_state.py +318 -0
- synth_ai/utils/user_config.py +137 -0
- synth_ai/v0/config/__init__.py +1 -5
- synth_ai/v0/config/base_url.py +1 -7
- synth_ai/v0/tracing/config.py +1 -1
- synth_ai/v0/tracing/decorators.py +1 -1
- synth_ai/v0/tracing/upload.py +1 -1
- synth_ai/v0/tracing_v1/config.py +1 -1
- synth_ai/v0/tracing_v1/decorators.py +1 -1
- synth_ai/v0/tracing_v1/upload.py +1 -1
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/METADATA +91 -32
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/RECORD +341 -154
- synth_ai/cli/man.py +0 -106
- synth_ai/cli/tui.py +0 -57
- synth_ai/compound/cais.py +0 -0
- synth_ai/core/experiment.py +0 -13
- synth_ai/core/system.py +0 -15
- synth_ai/demo_registry.py +0 -295
- synth_ai/handshake.py +0 -109
- 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 -906
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/top_level.txt +0 -0
synth_ai/utils/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PromptedChoiceType(click.Choice):
|
|
8
|
+
"""`click.Choice` variant that reprompts with an interactive menu on failure.
|
|
9
|
+
|
|
10
|
+
Example
|
|
11
|
+
-------
|
|
12
|
+
```python
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from synth_ai.utils.cli import PromptedChoiceType, PromptedChoiceOption
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.option(
|
|
20
|
+
"--mode",
|
|
21
|
+
cls=PromptedChoiceOption,
|
|
22
|
+
type=PromptedChoiceType(["sft", "rl"]),
|
|
23
|
+
required=True,
|
|
24
|
+
)
|
|
25
|
+
def train(mode: str) -> None:
|
|
26
|
+
click.echo(f"Selected mode: {mode}")
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def convert(
|
|
31
|
+
self,
|
|
32
|
+
value: Any,
|
|
33
|
+
param: click.Parameter | None,
|
|
34
|
+
ctx: click.Context | None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Validate *value*; prompt for a replacement when it is missing or invalid."""
|
|
37
|
+
if param is None:
|
|
38
|
+
raise RuntimeError("Invalid parameter")
|
|
39
|
+
if ctx is None:
|
|
40
|
+
raise RuntimeError("Invalid context")
|
|
41
|
+
if value in (None, ""):
|
|
42
|
+
return self._prompt_user(param, ctx)
|
|
43
|
+
try:
|
|
44
|
+
return super().convert(value, param, ctx)
|
|
45
|
+
except click.BadParameter:
|
|
46
|
+
cmd_name = self._get_cmd_name(ctx)
|
|
47
|
+
if getattr(param, "opts", None):
|
|
48
|
+
click.echo(f'\n[{cmd_name}] Invalid value "{value}" for {self._get_arg_name(param)}')
|
|
49
|
+
else:
|
|
50
|
+
click.echo(f'\n[{cmd_name}] Invalid value "{value}"')
|
|
51
|
+
return self._prompt_user(param, ctx)
|
|
52
|
+
|
|
53
|
+
def _prompt_user(
|
|
54
|
+
self,
|
|
55
|
+
param: click.Parameter,
|
|
56
|
+
ctx: click.Context | None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Render a numbered picker and return the user’s selection."""
|
|
59
|
+
arg_name = self._get_arg_name(param)
|
|
60
|
+
click.echo(f"\n[{self._get_cmd_name(ctx)}] Please choose a value for {arg_name}")
|
|
61
|
+
for index, choice in enumerate(self.choices, 1):
|
|
62
|
+
click.echo(f" [{index}] {choice}")
|
|
63
|
+
while True:
|
|
64
|
+
selection = click.prompt("> ", type=int)
|
|
65
|
+
if 1 <= selection <= len(self.choices):
|
|
66
|
+
return cast(str, self.choices[selection - 1])
|
|
67
|
+
click.echo(f"Invalid selection for {arg_name}, please try again")
|
|
68
|
+
|
|
69
|
+
def _get_cmd_name(self, ctx: click.Context | None) -> str:
|
|
70
|
+
"""Safely extract the current command name for diagnostic output."""
|
|
71
|
+
cmd = getattr(ctx, "command", None) if ctx is not None else None
|
|
72
|
+
if cmd is None:
|
|
73
|
+
return "?"
|
|
74
|
+
name = getattr(cmd, "name", None)
|
|
75
|
+
return name or "?"
|
|
76
|
+
|
|
77
|
+
def _get_arg_name(self, param: click.Parameter) -> str:
|
|
78
|
+
"""Return the most human-friendly identifier for the parameter."""
|
|
79
|
+
opts = getattr(param, "opts", None)
|
|
80
|
+
if opts:
|
|
81
|
+
return opts[-1]
|
|
82
|
+
name = getattr(param, "name", None)
|
|
83
|
+
if name:
|
|
84
|
+
return name
|
|
85
|
+
human_name = getattr(param, "human_readable_name", None)
|
|
86
|
+
if human_name:
|
|
87
|
+
return human_name
|
|
88
|
+
return "?"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PromptedChoiceOption(click.Option):
|
|
92
|
+
"""`click.Option` subclass that triggers the interactive picker when missing.
|
|
93
|
+
|
|
94
|
+
Example
|
|
95
|
+
-------
|
|
96
|
+
```python
|
|
97
|
+
import click
|
|
98
|
+
|
|
99
|
+
from synth_ai.utils.cli import PromptedChoiceType, PromptedChoiceOption
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@click.command()
|
|
103
|
+
@click.option(
|
|
104
|
+
"--mode",
|
|
105
|
+
cls=PromptedChoiceOption,
|
|
106
|
+
type=PromptedChoiceType(["sft", "rl"]),
|
|
107
|
+
required=True,
|
|
108
|
+
)
|
|
109
|
+
def train(mode: str) -> None:
|
|
110
|
+
click.echo(f"Selected mode: {mode}")
|
|
111
|
+
```
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
115
|
+
kwargs.setdefault("prompt", True)
|
|
116
|
+
kwargs.setdefault("prompt_required", True)
|
|
117
|
+
super().__init__(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def prompt_for_value(self, ctx: click.Context) -> Any:
|
|
120
|
+
"""Invoke the choice picker when the option was omitted."""
|
|
121
|
+
option_type = getattr(self, "type", None)
|
|
122
|
+
if isinstance(option_type, PromptedChoiceType):
|
|
123
|
+
return option_type._prompt_user(self, ctx)
|
|
124
|
+
return super().prompt_for_value(ctx)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def print_next_step(message: str, lines: Sequence[str]) -> None:
|
|
128
|
+
print(f"\n➡️ Next, {message}:")
|
|
129
|
+
for line in lines:
|
|
130
|
+
print(f" {line}")
|
|
131
|
+
print("")
|
synth_ai/utils/env.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import string
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
_ENV_SAFE_CHARS = set(string.ascii_letters + string.digits + "_-./:@+=")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_env_value(value: str) -> str:
|
|
12
|
+
if value == "":
|
|
13
|
+
return '""'
|
|
14
|
+
if all(char in _ENV_SAFE_CHARS for char in value):
|
|
15
|
+
return value
|
|
16
|
+
return json.dumps(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_inline_comment(value: str) -> str:
|
|
20
|
+
in_single = False
|
|
21
|
+
in_double = False
|
|
22
|
+
escaped = False
|
|
23
|
+
for idx, char in enumerate(value):
|
|
24
|
+
if escaped:
|
|
25
|
+
escaped = False
|
|
26
|
+
continue
|
|
27
|
+
if char == "\\":
|
|
28
|
+
escaped = True
|
|
29
|
+
continue
|
|
30
|
+
if char == "'" and not in_double:
|
|
31
|
+
in_single = not in_single
|
|
32
|
+
continue
|
|
33
|
+
if char == '"' and not in_single:
|
|
34
|
+
in_double = not in_double
|
|
35
|
+
continue
|
|
36
|
+
if char == '#' and not in_single and not in_double:
|
|
37
|
+
return value[:idx].rstrip()
|
|
38
|
+
return value.rstrip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_env_assignment(line: str) -> tuple[str, str] | None:
|
|
42
|
+
stripped = line.strip()
|
|
43
|
+
if not stripped or stripped.startswith('#'):
|
|
44
|
+
return None
|
|
45
|
+
if stripped.lower().startswith("export "):
|
|
46
|
+
stripped = stripped[7:].lstrip()
|
|
47
|
+
if '=' not in stripped:
|
|
48
|
+
return None
|
|
49
|
+
key_part, value_part = stripped.split('=', 1)
|
|
50
|
+
key = key_part.strip()
|
|
51
|
+
if not key:
|
|
52
|
+
return None
|
|
53
|
+
value_candidate = _strip_inline_comment(value_part.strip())
|
|
54
|
+
if not value_candidate:
|
|
55
|
+
return key, ""
|
|
56
|
+
if (
|
|
57
|
+
len(value_candidate) >= 2
|
|
58
|
+
and value_candidate[0] in {'"', "'"}
|
|
59
|
+
and value_candidate[-1] == value_candidate[0]
|
|
60
|
+
):
|
|
61
|
+
value = value_candidate[1:-1]
|
|
62
|
+
else:
|
|
63
|
+
value = value_candidate
|
|
64
|
+
return key, value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _prompt_manual_env_value(key: str) -> str:
|
|
68
|
+
while True:
|
|
69
|
+
value = click.prompt(
|
|
70
|
+
f"Enter value for {key}",
|
|
71
|
+
hide_input=False,
|
|
72
|
+
default="",
|
|
73
|
+
show_default=False,
|
|
74
|
+
type=str,
|
|
75
|
+
).strip()
|
|
76
|
+
if value:
|
|
77
|
+
return value
|
|
78
|
+
if click.confirm("Save empty value?", default=False):
|
|
79
|
+
return ""
|
|
80
|
+
click.echo("Empty value discarded; enter a value or confirm empty to continue")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mask_str(input: str, position: int = 3) -> str:
|
|
84
|
+
return input[:position] + "..." + input[-position:] if len(input) > position * 2 else "***"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
|
|
88
|
+
base = Path(base_dir).resolve()
|
|
89
|
+
return [path for path in base.rglob(".env*") if path.is_file()]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_synth_config_file_paths() -> list[Path]:
|
|
93
|
+
dir = Path.home() / ".synth-ai"
|
|
94
|
+
if not dir.exists():
|
|
95
|
+
return []
|
|
96
|
+
return [path for path in dir.glob("*.json") if path.is_file()]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def filter_env_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
|
|
100
|
+
matches: list[tuple[Path, str]] = []
|
|
101
|
+
for path in paths:
|
|
102
|
+
try:
|
|
103
|
+
with path.open('r', encoding="utf-8") as file:
|
|
104
|
+
for line in file:
|
|
105
|
+
parsed = _parse_env_assignment(line)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
continue
|
|
108
|
+
parsed_key, value = parsed
|
|
109
|
+
if parsed_key == key:
|
|
110
|
+
matches.append((path, value))
|
|
111
|
+
break
|
|
112
|
+
except (OSError, UnicodeDecodeError):
|
|
113
|
+
continue
|
|
114
|
+
return matches
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def filter_json_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
|
|
118
|
+
matches: list[tuple[Path, str]] = []
|
|
119
|
+
for path in paths:
|
|
120
|
+
try:
|
|
121
|
+
with path.open('r', encoding="utf-8") as file:
|
|
122
|
+
data = json.load(file)
|
|
123
|
+
if key in data and isinstance(data[key], str):
|
|
124
|
+
matches.append((path, data[key]))
|
|
125
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
126
|
+
continue
|
|
127
|
+
return matches
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_env_var(key: str, expected_value: str) -> None:
|
|
131
|
+
actual_value = os.getenv(key)
|
|
132
|
+
if expected_value != actual_value:
|
|
133
|
+
raise ValueError(f"Expected: {key}={expected_value}\nActual: {key}={actual_value}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolve_env_var(key: str) -> str:
|
|
137
|
+
env_value = os.getenv(key)
|
|
138
|
+
if env_value is not None:
|
|
139
|
+
click.echo(f"Using {key}={mask_str(env_value)} from process environment")
|
|
140
|
+
return env_value
|
|
141
|
+
|
|
142
|
+
value: str = ""
|
|
143
|
+
|
|
144
|
+
env_file_paths = filter_env_files_by_key(key, get_env_file_paths())
|
|
145
|
+
synth_file_paths = filter_json_files_by_key(key, get_synth_config_file_paths())
|
|
146
|
+
|
|
147
|
+
options: list[tuple[str, str]] = []
|
|
148
|
+
for path, value in env_file_paths:
|
|
149
|
+
resolved_path = path.resolve()
|
|
150
|
+
try:
|
|
151
|
+
rel_path = str(resolved_path.relative_to(Path.cwd()))
|
|
152
|
+
except ValueError:
|
|
153
|
+
rel_path = str(resolved_path)
|
|
154
|
+
label = f"({rel_path}) {mask_str(value)}"
|
|
155
|
+
options.append((label, value))
|
|
156
|
+
for path, value in synth_file_paths:
|
|
157
|
+
label = f"({path}) {mask_str(value)}"
|
|
158
|
+
options.append((label, value))
|
|
159
|
+
|
|
160
|
+
if options:
|
|
161
|
+
click.echo(f"\nFound the following options for {key}")
|
|
162
|
+
for i, (label, _) in enumerate(options, start=1):
|
|
163
|
+
click.echo(f" [{i}] {label}")
|
|
164
|
+
click.echo(" [m] Enter value manually")
|
|
165
|
+
click.echo()
|
|
166
|
+
|
|
167
|
+
while True:
|
|
168
|
+
try:
|
|
169
|
+
choice = click.prompt(
|
|
170
|
+
"Select option",
|
|
171
|
+
default=1,
|
|
172
|
+
type=str,
|
|
173
|
+
show_choices=False,
|
|
174
|
+
).strip()
|
|
175
|
+
except click.Abort:
|
|
176
|
+
raise
|
|
177
|
+
if choice.lower() == 'm':
|
|
178
|
+
value = _prompt_manual_env_value(key)
|
|
179
|
+
break
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
index = int(choice)
|
|
183
|
+
except ValueError:
|
|
184
|
+
click.echo('Invalid selection. Enter a number or "m".')
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if 1 <= index <= len(options):
|
|
188
|
+
_, value = options[index - 1]
|
|
189
|
+
break
|
|
190
|
+
|
|
191
|
+
click.echo(f"Invalid selection. Enter a number between 1 and {len(options)} or 'm'.")
|
|
192
|
+
|
|
193
|
+
else:
|
|
194
|
+
print(f"No value found for {key}")
|
|
195
|
+
value = _prompt_manual_env_value(key)
|
|
196
|
+
|
|
197
|
+
os.environ[key] = value
|
|
198
|
+
ensure_env_var(key, value)
|
|
199
|
+
print(f"Loaded {key}={mask_str(value)} into process environment")
|
|
200
|
+
return value
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def write_env_var_to_dotenv(
|
|
204
|
+
key: str,
|
|
205
|
+
value: str,
|
|
206
|
+
output_file_path: str | Path | None = None,
|
|
207
|
+
) -> None:
|
|
208
|
+
path = Path(".env") if output_file_path is None else Path(output_file_path)
|
|
209
|
+
path = path.expanduser()
|
|
210
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
|
|
212
|
+
encoded_value = _format_env_value(value)
|
|
213
|
+
|
|
214
|
+
lines: list[str] = []
|
|
215
|
+
key_written = False
|
|
216
|
+
|
|
217
|
+
if path.is_file():
|
|
218
|
+
try:
|
|
219
|
+
with path.open('r', encoding="utf-8") as handle:
|
|
220
|
+
lines = handle.readlines()
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
raise RuntimeError(f"Failed to read {path}: {exc}") from exc
|
|
223
|
+
|
|
224
|
+
for index, line in enumerate(lines):
|
|
225
|
+
parsed = _parse_env_assignment(line)
|
|
226
|
+
if parsed is None or parsed[0] != key:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
leading_len = len(line) - len(line.lstrip(' \t'))
|
|
230
|
+
leading = line[:leading_len]
|
|
231
|
+
stripped = line.lstrip()
|
|
232
|
+
has_export = stripped.lower().startswith('export ')
|
|
233
|
+
newline = '\n' if line.endswith('\n') else ''
|
|
234
|
+
prefix = 'export ' if has_export else ''
|
|
235
|
+
lines[index] = f"{leading}{prefix}{key}={encoded_value}{newline}"
|
|
236
|
+
key_written = True
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
if not key_written:
|
|
240
|
+
if lines and not lines[-1].endswith('\n'):
|
|
241
|
+
lines[-1] = f"{lines[-1]}\n"
|
|
242
|
+
lines.append(f"{key}={encoded_value}\n")
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
with path.open('w', encoding="utf-8") as handle:
|
|
246
|
+
handle.writelines(lines)
|
|
247
|
+
except OSError as exc:
|
|
248
|
+
raise RuntimeError(f"Failed to write {path}: {exc}") from exc
|
|
249
|
+
|
|
250
|
+
print(f"Wrote {key}={mask_str(value)} to {path.resolve()}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def write_env_var_to_json(
|
|
254
|
+
key: str,
|
|
255
|
+
value: str,
|
|
256
|
+
output_file_path: str | Path,
|
|
257
|
+
) -> None:
|
|
258
|
+
path = Path(output_file_path).expanduser()
|
|
259
|
+
if path.exists() and not path.is_file():
|
|
260
|
+
raise RuntimeError(f"{path} exists and is not a file")
|
|
261
|
+
|
|
262
|
+
data: dict[str, str] = {}
|
|
263
|
+
|
|
264
|
+
if path.is_file():
|
|
265
|
+
try:
|
|
266
|
+
with path.open('r', encoding="utf-8") as handle:
|
|
267
|
+
existing = json.load(handle)
|
|
268
|
+
except json.JSONDecodeError as exc:
|
|
269
|
+
raise RuntimeError(f"Invalid JSON in {path}: {exc}") from exc
|
|
270
|
+
except OSError as exc:
|
|
271
|
+
raise RuntimeError(f"Failed to read {path}: {exc}") from exc
|
|
272
|
+
|
|
273
|
+
if not isinstance(existing, dict):
|
|
274
|
+
raise RuntimeError(f"Expected JSON object in {path}")
|
|
275
|
+
|
|
276
|
+
for existing_key, existing_value in existing.items():
|
|
277
|
+
if existing_key == key:
|
|
278
|
+
continue
|
|
279
|
+
data[str(existing_key)] = (
|
|
280
|
+
existing_value if isinstance(existing_value, str) else str(existing_value)
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
data[key] = value
|
|
284
|
+
|
|
285
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
|
|
287
|
+
try:
|
|
288
|
+
with path.open('w', encoding="utf-8") as handle:
|
|
289
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
290
|
+
handle.write('\n')
|
|
291
|
+
except OSError as exc:
|
|
292
|
+
raise RuntimeError(f"Failed to write {path}: {exc}") from exc
|
|
293
|
+
|
|
294
|
+
print(f"Wrote {key}={mask_str(value)} to {path}")
|
synth_ai/utils/http.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
__all__ = ["HTTPError", "AsyncHttpClient", "http_request", "sleep"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HTTPError(Exception):
|
|
15
|
+
status: int
|
|
16
|
+
url: str
|
|
17
|
+
message: str
|
|
18
|
+
body_snippet: str | None = None
|
|
19
|
+
detail: Any | None = None
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
22
|
+
base = f"HTTP {self.status} for {self.url}: {self.message}"
|
|
23
|
+
if self.body_snippet:
|
|
24
|
+
base += f" | body[0:200]={self.body_snippet[:200]}"
|
|
25
|
+
return base
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncHttpClient:
|
|
29
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
|
|
30
|
+
self._base_url = base_url.rstrip("/")
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
33
|
+
self._session: aiohttp.ClientSession | None = None
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
36
|
+
if self._session is None:
|
|
37
|
+
headers = {
|
|
38
|
+
"authorization": f"Bearer {self._api_key}",
|
|
39
|
+
"accept": "application/json",
|
|
40
|
+
}
|
|
41
|
+
user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
|
|
42
|
+
if user_id:
|
|
43
|
+
headers["X-User-ID"] = user_id
|
|
44
|
+
org_id = os.getenv("SYNTH_ORG_ID") or os.getenv("X_ORG_ID") or os.getenv("ORG_ID")
|
|
45
|
+
if org_id:
|
|
46
|
+
headers["X-Org-ID"] = org_id
|
|
47
|
+
self._session = aiohttp.ClientSession(headers=headers, timeout=self._timeout)
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
|
51
|
+
if self._session is not None:
|
|
52
|
+
await self._session.close()
|
|
53
|
+
self._session = None
|
|
54
|
+
|
|
55
|
+
def _abs(self, path: str) -> str:
|
|
56
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
57
|
+
return path
|
|
58
|
+
if self._base_url.endswith("/api") and path.startswith("/api"):
|
|
59
|
+
path = path[4:]
|
|
60
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
61
|
+
|
|
62
|
+
async def get(
|
|
63
|
+
self,
|
|
64
|
+
path: str,
|
|
65
|
+
*,
|
|
66
|
+
params: dict[str, Any] | None = None,
|
|
67
|
+
headers: dict[str, str] | None = None,
|
|
68
|
+
) -> Any:
|
|
69
|
+
url = self._abs(path)
|
|
70
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
71
|
+
async with self._session.get(url, params=params, headers=headers) as resp:
|
|
72
|
+
return await self._handle_response(resp, url)
|
|
73
|
+
|
|
74
|
+
async def post_json(
|
|
75
|
+
self, path: str, *, json: dict[str, Any], headers: dict[str, str] | None = None
|
|
76
|
+
) -> Any:
|
|
77
|
+
url = self._abs(path)
|
|
78
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
79
|
+
async with self._session.post(url, json=json, headers=headers) as resp:
|
|
80
|
+
return await self._handle_response(resp, url)
|
|
81
|
+
|
|
82
|
+
async def post_multipart(
|
|
83
|
+
self,
|
|
84
|
+
path: str,
|
|
85
|
+
*,
|
|
86
|
+
data: dict[str, Any],
|
|
87
|
+
files: dict[str, tuple[str, bytes, str | None]],
|
|
88
|
+
headers: dict[str, str] | None = None,
|
|
89
|
+
) -> Any:
|
|
90
|
+
url = self._abs(path)
|
|
91
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
92
|
+
form = aiohttp.FormData()
|
|
93
|
+
for k, v in data.items():
|
|
94
|
+
form.add_field(k, str(v))
|
|
95
|
+
for field, (filename, content, content_type) in files.items():
|
|
96
|
+
form.add_field(
|
|
97
|
+
field,
|
|
98
|
+
content,
|
|
99
|
+
filename=filename,
|
|
100
|
+
content_type=content_type or "application/octet-stream",
|
|
101
|
+
)
|
|
102
|
+
async with self._session.post(url, data=form, headers=headers) as resp:
|
|
103
|
+
return await self._handle_response(resp, url)
|
|
104
|
+
|
|
105
|
+
async def delete(self, path: str, *, headers: dict[str, str] | None = None) -> Any:
|
|
106
|
+
url = self._abs(path)
|
|
107
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
108
|
+
async with self._session.delete(url, headers=headers) as resp:
|
|
109
|
+
return await self._handle_response(resp, url)
|
|
110
|
+
|
|
111
|
+
async def _handle_response(self, resp: aiohttp.ClientResponse, url: str) -> Any:
|
|
112
|
+
text = await resp.text()
|
|
113
|
+
body_snippet = text[:200] if text else None
|
|
114
|
+
if 200 <= resp.status < 300:
|
|
115
|
+
ctype = resp.headers.get("content-type", "")
|
|
116
|
+
if "application/json" in ctype:
|
|
117
|
+
try:
|
|
118
|
+
return await resp.json()
|
|
119
|
+
except Exception:
|
|
120
|
+
return text
|
|
121
|
+
return text
|
|
122
|
+
detail: Any | None = None
|
|
123
|
+
try:
|
|
124
|
+
detail = await resp.json()
|
|
125
|
+
except Exception:
|
|
126
|
+
detail = None
|
|
127
|
+
raise HTTPError(
|
|
128
|
+
status=resp.status,
|
|
129
|
+
url=url,
|
|
130
|
+
message="request_failed",
|
|
131
|
+
body_snippet=body_snippet,
|
|
132
|
+
detail=detail,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def http_request(
|
|
137
|
+
method: str, url: str, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None
|
|
138
|
+
) -> tuple[int, dict[str, Any] | str]:
|
|
139
|
+
import json as _json
|
|
140
|
+
import ssl
|
|
141
|
+
import urllib.error
|
|
142
|
+
import urllib.request
|
|
143
|
+
|
|
144
|
+
data = None
|
|
145
|
+
if body is not None:
|
|
146
|
+
data = _json.dumps(body).encode("utf-8")
|
|
147
|
+
req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
|
|
148
|
+
try:
|
|
149
|
+
ctx = ssl._create_unverified_context()
|
|
150
|
+
if os.getenv("SYNTH_SSL_VERIFY", "0") == "1":
|
|
151
|
+
ctx = None
|
|
152
|
+
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
|
|
153
|
+
code = getattr(resp, "status", 200)
|
|
154
|
+
txt = resp.read().decode("utf-8", errors="ignore")
|
|
155
|
+
try:
|
|
156
|
+
return int(code), _json.loads(txt)
|
|
157
|
+
except Exception:
|
|
158
|
+
return int(code), txt
|
|
159
|
+
except urllib.error.HTTPError as exc: # Capture 4xx/5xx bodies
|
|
160
|
+
txt = exc.read().decode("utf-8", errors="ignore")
|
|
161
|
+
try:
|
|
162
|
+
return int(exc.code or 0), _json.loads(txt)
|
|
163
|
+
except Exception:
|
|
164
|
+
return int(exc.code or 0), txt
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
return 0, str(exc)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def sleep(seconds: float) -> None:
|
|
170
|
+
"""Small async sleep helper preserved for backwards compatibility."""
|
|
171
|
+
|
|
172
|
+
await asyncio.sleep(max(float(seconds or 0.0), 0.0))
|