synth-ai 0.2.16__py3-none-any.whl → 0.2.19__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/baseline/banking77_baseline.py +204 -0
- examples/baseline/crafter_baseline.py +407 -0
- examples/baseline/pokemon_red_baseline.py +326 -0
- examples/baseline/simple_baseline.py +56 -0
- examples/baseline/warming_up_to_rl_baseline.py +239 -0
- examples/blog_posts/gepa/README.md +355 -0
- examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
- examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
- examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
- examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
- examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
- examples/blog_posts/gepa/gepa_baseline.py +204 -0
- examples/blog_posts/gepa/query_prompts_example.py +97 -0
- examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
- examples/blog_posts/gepa/task_apps.py +105 -0
- examples/blog_posts/gepa/test_gepa_local.sh +67 -0
- examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
- examples/blog_posts/pokemon_vl/README.md +98 -0
- examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
- examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +27 -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 +43 -0
- examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
- examples/blog_posts/pokemon_vl/extract_images.py +239 -0
- examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
- examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
- examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
- examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
- examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
- examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
- examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
- examples/blog_posts/warming_up_to_rl/README.md +158 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -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/smoke_test.toml +75 -0
- examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +91 -0
- examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
- examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
- examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +2 -1
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +65 -107
- examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +2 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple.toml +2 -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 +6 -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 -38
- examples/swe/task_app/hosted/policy_routes.py +17 -0
- examples/swe/task_app/hosted/rollout.py +4 -2
- examples/swe/task_app/morph_backend.py +178 -0
- examples/task_apps/banking77/__init__.py +6 -0
- examples/task_apps/banking77/banking77_task_app.py +841 -0
- examples/task_apps/banking77/deploy_wrapper.py +46 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
- examples/task_apps/crafter/task_app/README.md +1 -1
- examples/task_apps/crafter/task_app/grpo_crafter.py +90 -5
- 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/hosted_app.py +49 -0
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +372 -107
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +81 -12
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +82 -11
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
- examples/task_apps/gepa_benchmarks/__init__.py +7 -0
- examples/task_apps/gepa_benchmarks/common.py +260 -0
- examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
- examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
- examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
- examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
- examples/task_apps/math/README.md +1 -2
- examples/task_apps/pokemon_red/README.md +3 -4
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
- 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 +288 -39
- 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 +3 -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 +185 -5
- 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/hosted_app.py +49 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +156 -45
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +37 -4
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +6 -0
- synth_ai/api/train/builders.py +99 -4
- synth_ai/api/train/cli.py +516 -26
- synth_ai/api/train/config_finder.py +13 -2
- synth_ai/api/train/configs/__init__.py +23 -2
- synth_ai/api/train/configs/prompt_learning.py +442 -0
- synth_ai/api/train/configs/rl.py +61 -7
- synth_ai/api/train/configs/sft.py +6 -2
- synth_ai/api/train/configs/shared.py +59 -2
- synth_ai/api/train/task_app.py +1 -1
- synth_ai/api/train/validators.py +277 -0
- synth_ai/auth/credentials.py +119 -0
- synth_ai/baseline/__init__.py +25 -0
- synth_ai/baseline/config.py +209 -0
- synth_ai/baseline/discovery.py +214 -0
- synth_ai/baseline/execution.py +146 -0
- synth_ai/cli/__init__.py +94 -18
- synth_ai/cli/__main__.py +0 -0
- synth_ai/cli/claude.py +70 -0
- synth_ai/cli/codex.py +84 -0
- synth_ai/cli/commands/__init__.py +18 -0
- synth_ai/cli/commands/baseline/__init__.py +12 -0
- synth_ai/cli/commands/baseline/core.py +637 -0
- synth_ai/cli/commands/baseline/list.py +93 -0
- synth_ai/cli/commands/demo/__init__.py +6 -0
- synth_ai/cli/commands/demo/core.py +163 -0
- synth_ai/cli/commands/eval/__init__.py +19 -0
- synth_ai/cli/commands/eval/core.py +1112 -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 +424 -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 +72 -0
- synth_ai/cli/commands/smoke/__init__.py +7 -0
- synth_ai/cli/commands/smoke/core.py +1436 -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/pricing.py +22 -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/subcommands/usage.py +203 -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 +200 -0
- synth_ai/cli/commands/train/judge_validation.py +305 -0
- synth_ai/cli/commands/train/validation.py +386 -0
- synth_ai/cli/demo.py +30 -158
- synth_ai/cli/deploy/__init__.py +43 -0
- synth_ai/cli/deploy.py +162 -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 +14 -8
- 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/opencode.py +107 -0
- synth_ai/cli/root.py +9 -5
- 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 +51 -1480
- 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/crafter_classic/engine_deterministic_patch.py +7 -4
- synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
- synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
- 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 -10
- synth_ai/learning/__init__.py +10 -0
- synth_ai/learning/prompt_learning_client.py +276 -0
- synth_ai/learning/prompt_learning_types.py +184 -0
- synth_ai/learning/rl/client.py +3 -1
- synth_ai/pricing/__init__.py +2 -0
- synth_ai/pricing/model_pricing.py +57 -0
- synth_ai/streaming/__init__.py +29 -0
- synth_ai/streaming/config.py +94 -0
- synth_ai/streaming/handlers.py +518 -0
- synth_ai/streaming/streamer.py +320 -0
- synth_ai/streaming/types.py +95 -0
- synth_ai/task/apps/__init__.py +1 -0
- synth_ai/task/config.py +2 -0
- synth_ai/task/tracing_utils.py +25 -25
- synth_ai/task/validators.py +45 -9
- synth_ai/task_app_cfgs.py +21 -0
- synth_ai/tracing_v3/config.py +162 -19
- synth_ai/tracing_v3/constants.py +1 -1
- synth_ai/tracing_v3/db_config.py +24 -38
- synth_ai/tracing_v3/migration_helper.py +1 -2
- synth_ai/tracing_v3/storage/config.py +47 -13
- synth_ai/tracing_v3/storage/factory.py +3 -3
- synth_ai/tracing_v3/turso/daemon.py +113 -11
- synth_ai/tracing_v3/turso/native_manager.py +92 -16
- synth_ai/types.py +8 -0
- synth_ai/urls.py +11 -0
- synth_ai/utils/__init__.py +30 -1
- synth_ai/utils/agents.py +74 -0
- synth_ai/utils/bin.py +39 -0
- synth_ai/utils/cli.py +149 -5
- synth_ai/utils/env.py +40 -33
- synth_ai/utils/http.py +4 -1
- synth_ai/utils/json.py +72 -0
- synth_ai/utils/modal.py +285 -3
- synth_ai/utils/paths.py +48 -0
- synth_ai/utils/uvicorn.py +113 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/METADATA +109 -6
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/RECORD +291 -142
- 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.19.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import random
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
from synth_ai.http import AsyncHttpClient, sleep
|
|
9
|
+
|
|
10
|
+
from .config import StreamConfig
|
|
11
|
+
from .handlers import StreamHandler
|
|
12
|
+
from .types import StreamMessage, StreamType
|
|
13
|
+
|
|
14
|
+
TERMINAL_STATUSES = {"succeeded", "failed", "cancelled", "canceled", "completed"}
|
|
15
|
+
TERMINAL_EVENT_SUCCESS = {
|
|
16
|
+
"sft.job.completed",
|
|
17
|
+
"rl.train.completed",
|
|
18
|
+
"rl.job.completed",
|
|
19
|
+
"workflow.completed",
|
|
20
|
+
"training.completed",
|
|
21
|
+
}
|
|
22
|
+
TERMINAL_EVENT_FAILURE = {
|
|
23
|
+
"sft.job.failed",
|
|
24
|
+
"rl.train.failed",
|
|
25
|
+
"rl.job.failed",
|
|
26
|
+
"workflow.failed",
|
|
27
|
+
"training.failed",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class StreamEndpoints:
|
|
33
|
+
"""Collection of endpoint paths (with optional fallbacks) to poll for a job."""
|
|
34
|
+
|
|
35
|
+
status: str | None
|
|
36
|
+
events: str | None = None
|
|
37
|
+
metrics: str | None = None
|
|
38
|
+
timeline: str | None = None
|
|
39
|
+
status_fallbacks: tuple[str, ...] = ()
|
|
40
|
+
event_fallbacks: tuple[str, ...] = ()
|
|
41
|
+
metric_fallbacks: tuple[str, ...] = ()
|
|
42
|
+
timeline_fallbacks: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def learning(cls, job_id: str) -> StreamEndpoints:
|
|
46
|
+
base = f"/learning/jobs/{job_id}"
|
|
47
|
+
return cls(
|
|
48
|
+
status=base,
|
|
49
|
+
events=f"{base}/events",
|
|
50
|
+
metrics=f"{base}/metrics",
|
|
51
|
+
timeline=f"{base}/timeline",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def prompt_learning(cls, job_id: str) -> StreamEndpoints:
|
|
56
|
+
"""Endpoints for prompt learning jobs (MIPRO/GEPA)."""
|
|
57
|
+
base = f"/prompt-learning/online/jobs/{job_id}"
|
|
58
|
+
return cls(
|
|
59
|
+
status=base,
|
|
60
|
+
events=f"{base}/events",
|
|
61
|
+
metrics=f"{base}/metrics",
|
|
62
|
+
timeline=None,
|
|
63
|
+
status_fallbacks=(
|
|
64
|
+
f"/learning/jobs/{job_id}",
|
|
65
|
+
f"/orchestration/jobs/{job_id}",
|
|
66
|
+
),
|
|
67
|
+
event_fallbacks=(
|
|
68
|
+
f"/learning/jobs/{job_id}/events",
|
|
69
|
+
f"/orchestration/jobs/{job_id}/events",
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def rl(cls, job_id: str) -> StreamEndpoints:
|
|
75
|
+
base = f"/rl/jobs/{job_id}"
|
|
76
|
+
return cls(
|
|
77
|
+
status=base,
|
|
78
|
+
events=f"{base}/events",
|
|
79
|
+
metrics=f"{base}/metrics",
|
|
80
|
+
timeline=f"{base}/timeline",
|
|
81
|
+
status_fallbacks=(
|
|
82
|
+
f"/learning/jobs/{job_id}",
|
|
83
|
+
f"/orchestration/jobs/{job_id}",
|
|
84
|
+
),
|
|
85
|
+
event_fallbacks=(
|
|
86
|
+
f"/learning/jobs/{job_id}/events",
|
|
87
|
+
f"/orchestration/jobs/{job_id}/events",
|
|
88
|
+
),
|
|
89
|
+
metric_fallbacks=(
|
|
90
|
+
f"/learning/jobs/{job_id}/metrics",
|
|
91
|
+
),
|
|
92
|
+
timeline_fallbacks=(
|
|
93
|
+
f"/learning/jobs/{job_id}/timeline",
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class JobStreamer:
|
|
99
|
+
"""Poll job endpoints and dispatch messages to configured handlers."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
*,
|
|
104
|
+
base_url: str,
|
|
105
|
+
api_key: str,
|
|
106
|
+
job_id: str,
|
|
107
|
+
endpoints: StreamEndpoints | None = None,
|
|
108
|
+
config: StreamConfig | None = None,
|
|
109
|
+
handlers: Sequence[StreamHandler] | None = None,
|
|
110
|
+
interval_seconds: float = 2.0,
|
|
111
|
+
timeout_seconds: float | None = None,
|
|
112
|
+
http_timeout: float = 60.0,
|
|
113
|
+
http_client: AsyncHttpClient | None = None,
|
|
114
|
+
sleep_fn= sleep,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.base_url = base_url.rstrip("/")
|
|
117
|
+
self.api_key = api_key
|
|
118
|
+
self.job_id = job_id
|
|
119
|
+
self.endpoints = endpoints or StreamEndpoints.learning(job_id)
|
|
120
|
+
self.config = config or StreamConfig.default()
|
|
121
|
+
self.handlers: list[StreamHandler] = list(handlers or [])
|
|
122
|
+
self.interval_seconds = interval_seconds
|
|
123
|
+
self.timeout_seconds = timeout_seconds
|
|
124
|
+
self.http_timeout = http_timeout
|
|
125
|
+
self._http = http_client
|
|
126
|
+
self._sleep = sleep_fn
|
|
127
|
+
|
|
128
|
+
status_sources: list[str | None] = [self.endpoints.status]
|
|
129
|
+
status_sources.extend(self.endpoints.status_fallbacks)
|
|
130
|
+
self._status_paths = [p for p in status_sources if p]
|
|
131
|
+
|
|
132
|
+
event_sources: list[str | None] = [self.endpoints.events]
|
|
133
|
+
event_sources.extend(self.endpoints.event_fallbacks)
|
|
134
|
+
self._event_paths = [p for p in event_sources if p]
|
|
135
|
+
|
|
136
|
+
metric_sources: list[str | None] = [self.endpoints.metrics]
|
|
137
|
+
metric_sources.extend(self.endpoints.metric_fallbacks)
|
|
138
|
+
self._metric_paths = [p for p in metric_sources if p]
|
|
139
|
+
|
|
140
|
+
timeline_sources: list[str | None] = [self.endpoints.timeline]
|
|
141
|
+
timeline_sources.extend(self.endpoints.timeline_fallbacks)
|
|
142
|
+
self._timeline_paths = [p for p in timeline_sources if p]
|
|
143
|
+
|
|
144
|
+
self._last_seq_by_stream: dict[str, int] = {}
|
|
145
|
+
self._last_step_by_metric: dict[str, int] = {}
|
|
146
|
+
self._seen_messages: set[str] = set()
|
|
147
|
+
self._last_status_payload: dict[str, Any] | None = None
|
|
148
|
+
self._last_status_value: str | None = None
|
|
149
|
+
self._terminal_seen = False
|
|
150
|
+
self._terminal_event_status: str | None = None
|
|
151
|
+
|
|
152
|
+
if not self.handlers:
|
|
153
|
+
from .handlers import CLIHandler
|
|
154
|
+
|
|
155
|
+
self.handlers = [CLIHandler()]
|
|
156
|
+
|
|
157
|
+
async def stream_until_terminal(self) -> dict[str, Any]:
|
|
158
|
+
"""Stream configured endpoints until the job reaches a terminal state."""
|
|
159
|
+
http_cm = self._http or AsyncHttpClient(self.base_url, self.api_key, timeout=self.http_timeout)
|
|
160
|
+
async with http_cm as http:
|
|
161
|
+
while True:
|
|
162
|
+
status = await self._refresh_status(http)
|
|
163
|
+
|
|
164
|
+
event_messages = await self._poll_events(http)
|
|
165
|
+
metric_messages = await self._poll_metrics(http)
|
|
166
|
+
timeline_messages = await self._poll_timeline(http)
|
|
167
|
+
|
|
168
|
+
self._dispatch(event_messages + metric_messages + timeline_messages)
|
|
169
|
+
|
|
170
|
+
if self._terminal_seen or (status and status in TERMINAL_STATUSES):
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
await self._sleep(self.interval_seconds)
|
|
174
|
+
|
|
175
|
+
for handler in self.handlers:
|
|
176
|
+
with contextlib.suppress(Exception):
|
|
177
|
+
handler.flush()
|
|
178
|
+
|
|
179
|
+
final_status = self._terminal_event_status or self._last_status_value or "unknown"
|
|
180
|
+
if self._last_status_payload:
|
|
181
|
+
self._last_status_payload["status"] = final_status
|
|
182
|
+
return self._last_status_payload
|
|
183
|
+
return {"job_id": self.job_id, "status": final_status}
|
|
184
|
+
|
|
185
|
+
async def _refresh_status(self, http: AsyncHttpClient) -> str:
|
|
186
|
+
status_payload = await self._poll_status(http)
|
|
187
|
+
if status_payload:
|
|
188
|
+
self._last_status_payload = status_payload
|
|
189
|
+
status = str(status_payload.get("status") or status_payload.get("state") or "").lower()
|
|
190
|
+
if status:
|
|
191
|
+
self._last_status_value = status
|
|
192
|
+
if status in TERMINAL_STATUSES:
|
|
193
|
+
self._terminal_seen = True
|
|
194
|
+
return status
|
|
195
|
+
return self._last_status_value or ""
|
|
196
|
+
|
|
197
|
+
async def _poll_status(self, http: AsyncHttpClient) -> dict[str, Any] | None:
|
|
198
|
+
if StreamType.STATUS not in self.config.enabled_streams or not self._status_paths:
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
for path in self._status_paths:
|
|
202
|
+
try:
|
|
203
|
+
data = await http.get(path)
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
if isinstance(data, dict):
|
|
207
|
+
message = StreamMessage.from_status(self.job_id, data)
|
|
208
|
+
self._dispatch([message])
|
|
209
|
+
return data
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
async def _poll_events(self, http: AsyncHttpClient) -> list[StreamMessage]:
|
|
213
|
+
if StreamType.EVENTS not in self.config.enabled_streams or not self._event_paths:
|
|
214
|
+
return []
|
|
215
|
+
messages: list[StreamMessage] = []
|
|
216
|
+
total = 0
|
|
217
|
+
for path in self._event_paths:
|
|
218
|
+
since = self._last_seq_by_stream.get(path, 0)
|
|
219
|
+
params = {"since_seq": since, "limit": 200}
|
|
220
|
+
try:
|
|
221
|
+
data = await http.get(path, params=params)
|
|
222
|
+
except Exception:
|
|
223
|
+
continue
|
|
224
|
+
raw_events = _extract_list(data, "events")
|
|
225
|
+
for event in raw_events:
|
|
226
|
+
seq = int(event.get("seq") or 0)
|
|
227
|
+
if seq <= self._last_seq_by_stream.get(path, 0):
|
|
228
|
+
continue
|
|
229
|
+
if not self.config.should_include_event(event):
|
|
230
|
+
continue
|
|
231
|
+
self._last_seq_by_stream[path] = seq
|
|
232
|
+
event_job_id = event.get("job_id") or self.job_id
|
|
233
|
+
event_message = StreamMessage.from_event(event_job_id, event)
|
|
234
|
+
event_type = str(event.get("type") or "").lower()
|
|
235
|
+
if event_type in TERMINAL_EVENT_SUCCESS:
|
|
236
|
+
self._terminal_seen = True
|
|
237
|
+
self._terminal_event_status = "succeeded"
|
|
238
|
+
elif event_type in TERMINAL_EVENT_FAILURE:
|
|
239
|
+
self._terminal_seen = True
|
|
240
|
+
self._terminal_event_status = "failed"
|
|
241
|
+
messages.append(event_message)
|
|
242
|
+
total += 1
|
|
243
|
+
if self.config.max_events_per_poll and total >= self.config.max_events_per_poll:
|
|
244
|
+
return messages
|
|
245
|
+
return messages
|
|
246
|
+
|
|
247
|
+
async def _poll_metrics(self, http: AsyncHttpClient) -> list[StreamMessage]:
|
|
248
|
+
if StreamType.METRICS not in self.config.enabled_streams or not self._metric_paths:
|
|
249
|
+
return []
|
|
250
|
+
messages: list[StreamMessage] = []
|
|
251
|
+
for path in self._metric_paths:
|
|
252
|
+
after = max(self._last_step_by_metric.values()) if self._last_step_by_metric else -1
|
|
253
|
+
params = {"after_step": after, "limit": 200}
|
|
254
|
+
try:
|
|
255
|
+
data = await http.get(path, params=params)
|
|
256
|
+
except Exception:
|
|
257
|
+
continue
|
|
258
|
+
points = _extract_list(data, "points")
|
|
259
|
+
for point in points:
|
|
260
|
+
name = point.get("name", "")
|
|
261
|
+
step = int(point.get("step") or -1)
|
|
262
|
+
if step <= self._last_step_by_metric.get(name, -1):
|
|
263
|
+
continue
|
|
264
|
+
if not self.config.should_include_metric(point):
|
|
265
|
+
continue
|
|
266
|
+
self._last_step_by_metric[name] = step
|
|
267
|
+
metric_job_id = point.get("job_id") or self.job_id
|
|
268
|
+
messages.append(StreamMessage.from_metric(metric_job_id, point))
|
|
269
|
+
return messages
|
|
270
|
+
|
|
271
|
+
async def _poll_timeline(self, http: AsyncHttpClient) -> list[StreamMessage]:
|
|
272
|
+
if StreamType.TIMELINE not in self.config.enabled_streams or not self._timeline_paths:
|
|
273
|
+
return []
|
|
274
|
+
messages: list[StreamMessage] = []
|
|
275
|
+
for path in self._timeline_paths:
|
|
276
|
+
try:
|
|
277
|
+
data = await http.get(path)
|
|
278
|
+
except Exception:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
timeline_entries = _extract_list(data, "events")
|
|
282
|
+
for entry in timeline_entries:
|
|
283
|
+
if not self.config.should_include_timeline(entry):
|
|
284
|
+
continue
|
|
285
|
+
timeline_job_id = entry.get("job_id") or self.job_id
|
|
286
|
+
phase = str(entry.get("phase") or "").lower()
|
|
287
|
+
if phase in TERMINAL_STATUSES:
|
|
288
|
+
self._terminal_seen = True
|
|
289
|
+
if phase in {"failed", "cancelled", "canceled"}:
|
|
290
|
+
self._terminal_event_status = "failed"
|
|
291
|
+
elif phase:
|
|
292
|
+
self._terminal_event_status = "succeeded"
|
|
293
|
+
messages.append(StreamMessage.from_timeline(timeline_job_id, entry))
|
|
294
|
+
return messages
|
|
295
|
+
|
|
296
|
+
def _dispatch(self, messages: Iterable[StreamMessage]) -> None:
|
|
297
|
+
for message in messages:
|
|
298
|
+
if self.config.deduplicate and message.key in self._seen_messages:
|
|
299
|
+
continue
|
|
300
|
+
if self.config.sample_rate < 1.0 and random.random() > self.config.sample_rate:
|
|
301
|
+
continue
|
|
302
|
+
if self.config.deduplicate:
|
|
303
|
+
self._seen_messages.add(message.key)
|
|
304
|
+
|
|
305
|
+
for handler in self.handlers:
|
|
306
|
+
try:
|
|
307
|
+
if handler.should_handle(message):
|
|
308
|
+
handler.handle(message)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _extract_list(data: Any, field: str) -> list[dict[str, Any]]:
|
|
314
|
+
raw = (data or {}).get(field) if isinstance(data, dict) else None
|
|
315
|
+
if isinstance(raw, list):
|
|
316
|
+
return [item for item in raw if isinstance(item, dict)]
|
|
317
|
+
return []
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
__all__ = ["JobStreamer", "StreamEndpoints"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StreamType(Enum):
|
|
9
|
+
"""Categories of streaming payloads emitted by training jobs."""
|
|
10
|
+
|
|
11
|
+
STATUS = auto()
|
|
12
|
+
EVENTS = auto()
|
|
13
|
+
METRICS = auto()
|
|
14
|
+
TIMELINE = auto()
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def endpoint_path(self) -> str:
|
|
18
|
+
"""Return the endpoint suffix used when polling this stream."""
|
|
19
|
+
return {
|
|
20
|
+
StreamType.STATUS: "",
|
|
21
|
+
StreamType.EVENTS: "/events",
|
|
22
|
+
StreamType.METRICS: "/metrics",
|
|
23
|
+
StreamType.TIMELINE: "/timeline",
|
|
24
|
+
}[self]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class StreamMessage:
|
|
29
|
+
"""Unified representation of a streaming payload."""
|
|
30
|
+
|
|
31
|
+
stream_type: StreamType
|
|
32
|
+
timestamp: str
|
|
33
|
+
job_id: str
|
|
34
|
+
data: dict[str, Any]
|
|
35
|
+
seq: int | None = None
|
|
36
|
+
step: int | None = None
|
|
37
|
+
phase: str | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def key(self) -> str:
|
|
41
|
+
"""Return a unique identifier used for deduplication."""
|
|
42
|
+
if self.stream_type is StreamType.EVENTS:
|
|
43
|
+
return f"event:{self.seq}"
|
|
44
|
+
if self.stream_type is StreamType.METRICS:
|
|
45
|
+
name = self.data.get("name", "")
|
|
46
|
+
return f"metric:{name}:{self.step}"
|
|
47
|
+
if self.stream_type is StreamType.TIMELINE:
|
|
48
|
+
return f"timeline:{self.phase}:{self.timestamp}"
|
|
49
|
+
return f"status:{self.timestamp}"
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_status(cls, job_id: str, status_data: dict[str, Any]) -> StreamMessage:
|
|
53
|
+
"""Create a message representing a job status payload."""
|
|
54
|
+
return cls(
|
|
55
|
+
stream_type=StreamType.STATUS,
|
|
56
|
+
timestamp=status_data.get("updated_at", "") or status_data.get("created_at", ""),
|
|
57
|
+
job_id=job_id,
|
|
58
|
+
data=status_data,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_event(cls, job_id: str, event_data: dict[str, Any]) -> StreamMessage:
|
|
63
|
+
"""Create a message describing a job event."""
|
|
64
|
+
return cls(
|
|
65
|
+
stream_type=StreamType.EVENTS,
|
|
66
|
+
timestamp=event_data.get("created_at", ""),
|
|
67
|
+
job_id=job_id,
|
|
68
|
+
data=event_data,
|
|
69
|
+
seq=event_data.get("seq"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def from_metric(cls, job_id: str, metric_data: dict[str, Any]) -> StreamMessage:
|
|
74
|
+
"""Create a message describing a metric point."""
|
|
75
|
+
return cls(
|
|
76
|
+
stream_type=StreamType.METRICS,
|
|
77
|
+
timestamp=metric_data.get("created_at", ""),
|
|
78
|
+
job_id=job_id,
|
|
79
|
+
data=metric_data,
|
|
80
|
+
step=metric_data.get("step"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_timeline(cls, job_id: str, timeline_data: dict[str, Any]) -> StreamMessage:
|
|
85
|
+
"""Create a message describing a status timeline entry."""
|
|
86
|
+
return cls(
|
|
87
|
+
stream_type=StreamType.TIMELINE,
|
|
88
|
+
timestamp=timeline_data.get("created_at", ""),
|
|
89
|
+
job_id=job_id,
|
|
90
|
+
data=timeline_data,
|
|
91
|
+
phase=timeline_data.get("phase"),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = ["StreamMessage", "StreamType"]
|
synth_ai/task/apps/__init__.py
CHANGED
|
@@ -22,6 +22,7 @@ class ModalDeploymentConfig:
|
|
|
22
22
|
extra_local_dirs: Sequence[tuple[str, str]] = field(default_factory=tuple)
|
|
23
23
|
secret_names: Sequence[str] = field(default_factory=tuple)
|
|
24
24
|
volume_mounts: Sequence[tuple[str, str]] = field(default_factory=tuple)
|
|
25
|
+
env_vars: dict[str, str] = field(default_factory=dict)
|
|
25
26
|
timeout: int = 600
|
|
26
27
|
memory: int = 4096
|
|
27
28
|
cpu: float = 2.0
|
synth_ai/task/config.py
CHANGED
synth_ai/task/tracing_utils.py
CHANGED
|
@@ -26,34 +26,34 @@ def tracing_env_enabled(default: bool = False) -> bool:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def resolve_tracing_db_url() -> str | None:
|
|
29
|
-
"""Resolve tracing database URL
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
"""Resolve tracing database URL using centralized tracing_v3 config logic.
|
|
30
|
+
|
|
31
|
+
This delegates to synth_ai.tracing_v3.config.resolve_trace_db_settings() which
|
|
32
|
+
handles Modal detection, remote Turso, local sqld, and SQLite fallbacks.
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from synth_ai.tracing_v3.config import resolve_trace_db_settings
|
|
36
|
+
db_url, _ = resolve_trace_db_settings(ensure_dir=True)
|
|
33
37
|
return db_url
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
existing = os.getenv("TASKAPP_TRACE_DB_PATH")
|
|
47
|
-
if existing:
|
|
48
|
-
path = Path(existing).expanduser()
|
|
49
|
-
else:
|
|
38
|
+
except ImportError:
|
|
39
|
+
# Fallback if tracing_v3 is not available (shouldn't happen in normal usage)
|
|
40
|
+
db_url = (
|
|
41
|
+
os.getenv("TURSO_LOCAL_DB_URL")
|
|
42
|
+
or os.getenv("LIBSQL_URL")
|
|
43
|
+
or os.getenv("SYNTH_TRACES_DB")
|
|
44
|
+
)
|
|
45
|
+
if db_url:
|
|
46
|
+
return db_url
|
|
47
|
+
|
|
48
|
+
# Auto-provision local sqld location for callers that rely on trace directories.
|
|
50
49
|
base_dir = TRACE_DB_DIR.expanduser()
|
|
51
50
|
base_dir.mkdir(parents=True, exist_ok=True)
|
|
52
|
-
|
|
53
|
-
os.environ["TASKAPP_TRACE_DB_PATH"] = str(
|
|
54
|
-
os.environ.setdefault("SQLD_DB_PATH", str(
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
candidate = base_dir / canonical_trace_db_name(timestamp=datetime.now())
|
|
52
|
+
os.environ["TASKAPP_TRACE_DB_PATH"] = str(candidate)
|
|
53
|
+
os.environ.setdefault("SQLD_DB_PATH", str(candidate))
|
|
54
|
+
|
|
55
|
+
default_url = os.getenv("LIBSQL_DEFAULT_URL", "http://127.0.0.1:8081")
|
|
56
|
+
return default_url
|
|
57
57
|
|
|
58
58
|
|
|
59
59
|
def build_tracer_factory(
|
synth_ai/task/validators.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
from typing import Any
|
|
6
|
+
from typing import Any
|
|
7
7
|
from urllib.parse import urlparse, urlunparse
|
|
8
8
|
|
|
9
9
|
import click
|
|
@@ -133,13 +133,46 @@ def normalize_inference_url(url: str | None, *, default: str = "https://api.open
|
|
|
133
133
|
if not candidate:
|
|
134
134
|
candidate = default
|
|
135
135
|
|
|
136
|
-
# Parse the URL to separate path and query components
|
|
137
136
|
parsed = urlparse(candidate)
|
|
138
|
-
|
|
137
|
+
path = (parsed.path or "").rstrip("/")
|
|
138
|
+
query = parsed.query or ""
|
|
139
|
+
|
|
140
|
+
# Repair malformed URLs where the completions path ended up in the query string.
|
|
141
|
+
# Example: https://host?cid=trace/v1/chat/completions -> https://host/v1/chat/completions?cid=trace
|
|
142
|
+
if query and "/" in query:
|
|
143
|
+
base_query, remainder = query.split("/", 1)
|
|
144
|
+
remainder_path = remainder
|
|
145
|
+
extra_query = ""
|
|
146
|
+
for separator in ("&", "?"):
|
|
147
|
+
idx = remainder_path.find(separator)
|
|
148
|
+
if idx != -1:
|
|
149
|
+
extra_query = remainder_path[idx + 1 :]
|
|
150
|
+
remainder_path = remainder_path[:idx]
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
query_path = "/" + remainder_path.lstrip("/")
|
|
154
|
+
merged_query_parts: list[str] = []
|
|
155
|
+
if base_query:
|
|
156
|
+
merged_query_parts.append(base_query)
|
|
157
|
+
if extra_query:
|
|
158
|
+
merged_query_parts.append(extra_query)
|
|
159
|
+
merged_query = "&".join(part for part in merged_query_parts if part)
|
|
160
|
+
|
|
161
|
+
if query_path and query_path != "/":
|
|
162
|
+
combined_path = f"{path.rstrip('/')}{query_path}" if path else query_path
|
|
163
|
+
else:
|
|
164
|
+
combined_path = path
|
|
165
|
+
|
|
166
|
+
parsed = parsed._replace(path=combined_path or "", query=merged_query)
|
|
167
|
+
path = (parsed.path or "").rstrip("/")
|
|
168
|
+
query = parsed.query or ""
|
|
169
|
+
|
|
139
170
|
# Check if path already ends with a completions endpoint
|
|
140
|
-
path = parsed.path.rstrip('/')
|
|
141
171
|
if path.endswith("/v1/chat/completions") or path.endswith("/chat/completions"):
|
|
142
|
-
|
|
172
|
+
final_query = parsed.query or ""
|
|
173
|
+
if final_query and "/" in final_query:
|
|
174
|
+
parsed = parsed._replace(query=final_query.split("/", 1)[0])
|
|
175
|
+
return urlunparse(parsed)
|
|
143
176
|
|
|
144
177
|
# Determine what to append based on existing path
|
|
145
178
|
if path.endswith("/v1"):
|
|
@@ -147,11 +180,14 @@ def normalize_inference_url(url: str | None, *, default: str = "https://api.open
|
|
|
147
180
|
elif path.endswith("/chat"):
|
|
148
181
|
new_path = f"{path}/completions"
|
|
149
182
|
else:
|
|
150
|
-
# Default: append full path
|
|
151
183
|
new_path = f"{path}/v1/chat/completions" if path else "/v1/chat/completions"
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
184
|
+
|
|
185
|
+
parsed = parsed._replace(path=new_path)
|
|
186
|
+
final_query = parsed.query or ""
|
|
187
|
+
if final_query and "/" in final_query:
|
|
188
|
+
parsed = parsed._replace(query=final_query.split("/", 1)[0])
|
|
189
|
+
|
|
190
|
+
return urlunparse(parsed)
|
|
155
191
|
|
|
156
192
|
|
|
157
193
|
def validate_task_app_url(url: str | None) -> str:
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LocalTaskAppConfig(BaseModel):
|
|
8
|
+
task_app_path: Path
|
|
9
|
+
trace: bool = True
|
|
10
|
+
host: str = "127.0.0.1"
|
|
11
|
+
port: int = 8000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModalTaskAppConfig(BaseModel):
|
|
16
|
+
task_app_path: Path
|
|
17
|
+
modal_app_path: Path
|
|
18
|
+
modal_bin_path: Path
|
|
19
|
+
cmd_arg: Literal["deploy", "serve"] = "deploy"
|
|
20
|
+
task_app_name: Optional[str] = None
|
|
21
|
+
dry_run: bool = False
|