hud-python 0.4.35__py3-none-any.whl → 0.4.37__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 hud-python might be problematic. Click here for more details.
- hud/agents/__init__.py +2 -0
- hud/agents/lite_llm.py +72 -0
- hud/agents/openai_chat_generic.py +21 -7
- hud/agents/tests/test_claude.py +32 -7
- hud/agents/tests/test_openai.py +29 -6
- hud/cli/__init__.py +228 -79
- hud/cli/build.py +26 -6
- hud/cli/dev.py +21 -40
- hud/cli/eval.py +96 -15
- hud/cli/flows/tasks.py +198 -65
- hud/cli/init.py +222 -629
- hud/cli/pull.py +6 -0
- hud/cli/push.py +11 -1
- hud/cli/rl/__init__.py +14 -4
- hud/cli/rl/celebrate.py +187 -0
- hud/cli/rl/config.py +15 -8
- hud/cli/rl/local_runner.py +44 -20
- hud/cli/rl/remote_runner.py +166 -87
- hud/cli/rl/viewer.py +141 -0
- hud/cli/rl/wait_utils.py +89 -0
- hud/cli/tests/test_build.py +3 -27
- hud/cli/tests/test_mcp_server.py +1 -12
- hud/cli/utils/config.py +85 -0
- hud/cli/utils/docker.py +21 -39
- hud/cli/utils/env_check.py +196 -0
- hud/cli/utils/environment.py +4 -3
- hud/cli/utils/interactive.py +2 -1
- hud/cli/utils/local_runner.py +204 -0
- hud/cli/utils/metadata.py +3 -1
- hud/cli/utils/package_runner.py +292 -0
- hud/cli/utils/remote_runner.py +4 -1
- hud/cli/utils/source_hash.py +108 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +1 -1
- hud/clients/mcp_use.py +30 -7
- hud/datasets/parallel.py +3 -1
- hud/datasets/runner.py +4 -1
- hud/otel/config.py +1 -1
- hud/otel/context.py +40 -6
- hud/rl/buffer.py +3 -0
- hud/rl/tests/test_learner.py +1 -1
- hud/rl/vllm_adapter.py +1 -1
- hud/server/server.py +234 -7
- hud/server/tests/test_add_tool.py +60 -0
- hud/server/tests/test_context.py +128 -0
- hud/server/tests/test_mcp_server_handlers.py +44 -0
- hud/server/tests/test_mcp_server_integration.py +405 -0
- hud/server/tests/test_mcp_server_more.py +247 -0
- hud/server/tests/test_run_wrapper.py +53 -0
- hud/server/tests/test_server_extra.py +166 -0
- hud/server/tests/test_sigterm_runner.py +78 -0
- hud/settings.py +38 -0
- hud/shared/hints.py +2 -2
- hud/telemetry/job.py +2 -2
- hud/types.py +9 -2
- hud/utils/tasks.py +32 -24
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/METADATA +43 -23
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/RECORD +63 -46
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.35.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
hud/cli/eval.py
CHANGED
|
@@ -5,15 +5,18 @@ from __future__ import annotations
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import logging
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Any, Literal
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
9
9
|
|
|
10
10
|
import typer
|
|
11
11
|
|
|
12
12
|
import hud
|
|
13
|
+
from hud.cli.utils.env_check import ensure_built, find_environment_dir
|
|
13
14
|
from hud.settings import settings
|
|
14
15
|
from hud.utils.group_eval import display_group_statistics, run_tasks_grouped
|
|
15
16
|
from hud.utils.hud_console import HUDConsole
|
|
16
17
|
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from hud.types import Task
|
|
17
20
|
logger = logging.getLogger(__name__)
|
|
18
21
|
hud_console = HUDConsole()
|
|
19
22
|
|
|
@@ -27,7 +30,7 @@ def get_available_models() -> list[dict[str, str | None]]:
|
|
|
27
30
|
try:
|
|
28
31
|
from hud.cli.rl import rl_api
|
|
29
32
|
|
|
30
|
-
hud_console.info("Fetching your models from https://
|
|
33
|
+
hud_console.info("Fetching your models from https://hud.so/models")
|
|
31
34
|
models = rl_api.list_models()
|
|
32
35
|
|
|
33
36
|
# Filter for ready models only and sort by recency
|
|
@@ -66,7 +69,7 @@ def get_available_models() -> list[dict[str, str | None]]:
|
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def build_agent(
|
|
69
|
-
agent_type: Literal["claude", "openai", "vllm"],
|
|
72
|
+
agent_type: Literal["claude", "openai", "vllm", "litellm"],
|
|
70
73
|
*,
|
|
71
74
|
model: str | None = None,
|
|
72
75
|
allowed_tools: list[str] | None = None,
|
|
@@ -138,6 +141,22 @@ def build_agent(
|
|
|
138
141
|
else:
|
|
139
142
|
return OperatorAgent(verbose=verbose)
|
|
140
143
|
|
|
144
|
+
elif agent_type == "litellm":
|
|
145
|
+
try:
|
|
146
|
+
from hud.agents.lite_llm import LiteAgent
|
|
147
|
+
except ImportError as e:
|
|
148
|
+
hud_console.error(
|
|
149
|
+
"LiteLLM agent dependencies are not installed. "
|
|
150
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
151
|
+
)
|
|
152
|
+
raise typer.Exit(1) from e
|
|
153
|
+
|
|
154
|
+
return LiteAgent(
|
|
155
|
+
model_name=model or "gpt-4o-mini",
|
|
156
|
+
allowed_tools=allowed_tools,
|
|
157
|
+
verbose=verbose,
|
|
158
|
+
)
|
|
159
|
+
|
|
141
160
|
# Fallback Claude agent (Anthropic)
|
|
142
161
|
try:
|
|
143
162
|
from hud.agents import ClaudeAgent
|
|
@@ -166,7 +185,7 @@ def build_agent(
|
|
|
166
185
|
async def run_single_task(
|
|
167
186
|
source: str,
|
|
168
187
|
*,
|
|
169
|
-
agent_type: Literal["claude", "openai", "vllm"] = "claude",
|
|
188
|
+
agent_type: Literal["claude", "openai", "vllm", "litellm"] = "claude",
|
|
170
189
|
model: str | None = None,
|
|
171
190
|
allowed_tools: list[str] | None = None,
|
|
172
191
|
max_steps: int = 10,
|
|
@@ -192,7 +211,16 @@ async def run_single_task(
|
|
|
192
211
|
hud_console.info("📊 Loading task file…")
|
|
193
212
|
|
|
194
213
|
# Use unified loader for both JSON and JSONL
|
|
195
|
-
tasks = load_tasks(str(path))
|
|
214
|
+
tasks: list[Task] = load_tasks(str(path)) # type: ignore[assignment]
|
|
215
|
+
|
|
216
|
+
# If tasks reference a local environment (nearby), ensure it's built/up-to-date.
|
|
217
|
+
try:
|
|
218
|
+
env_dir = find_environment_dir(path)
|
|
219
|
+
if env_dir is not None:
|
|
220
|
+
# Non-interactive for eval; warn but don't block
|
|
221
|
+
ensure_built(env_dir, interactive=True)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
hud_console.debug(f"Eval preflight env check skipped: {e}")
|
|
196
224
|
|
|
197
225
|
# Single task - use the first (and only) task
|
|
198
226
|
task = tasks[0]
|
|
@@ -200,7 +228,7 @@ async def run_single_task(
|
|
|
200
228
|
else:
|
|
201
229
|
# Load from HuggingFace dataset or non-file source
|
|
202
230
|
hud_console.info(f"📊 Loading tasks from: {source}…")
|
|
203
|
-
tasks = load_tasks(source)
|
|
231
|
+
tasks: list[Task] = load_tasks(source) # type: ignore[assignment]
|
|
204
232
|
|
|
205
233
|
if not tasks:
|
|
206
234
|
hud_console.error(f"No tasks found in: {source}")
|
|
@@ -248,6 +276,16 @@ async def run_single_task(
|
|
|
248
276
|
agent_config = {"verbose": verbose}
|
|
249
277
|
if allowed_tools:
|
|
250
278
|
agent_config["allowed_tools"] = allowed_tools
|
|
279
|
+
elif agent_type == "litellm":
|
|
280
|
+
from hud.agents.lite_llm import LiteAgent
|
|
281
|
+
|
|
282
|
+
agent_class = LiteAgent
|
|
283
|
+
agent_config = {
|
|
284
|
+
"model_name": model or "gpt-4o-mini",
|
|
285
|
+
"verbose": verbose,
|
|
286
|
+
}
|
|
287
|
+
if allowed_tools:
|
|
288
|
+
agent_config["allowed_tools"] = allowed_tools
|
|
251
289
|
else:
|
|
252
290
|
from hud.agents import ClaudeAgent
|
|
253
291
|
|
|
@@ -292,7 +330,7 @@ async def run_single_task(
|
|
|
292
330
|
async def run_full_dataset(
|
|
293
331
|
source: str,
|
|
294
332
|
*,
|
|
295
|
-
agent_type: Literal["claude", "openai", "vllm"] = "claude",
|
|
333
|
+
agent_type: Literal["claude", "openai", "vllm", "litellm"] = "claude",
|
|
296
334
|
model: str | None = None,
|
|
297
335
|
allowed_tools: list[str] | None = None,
|
|
298
336
|
max_concurrent: int = 30,
|
|
@@ -322,7 +360,7 @@ async def run_full_dataset(
|
|
|
322
360
|
|
|
323
361
|
# Load tasks using unified loader
|
|
324
362
|
hud_console.info(f"📊 Loading tasks from: {source}…")
|
|
325
|
-
tasks = load_tasks(source)
|
|
363
|
+
tasks: list[Task] = load_tasks(source) # type: ignore[assignment]
|
|
326
364
|
|
|
327
365
|
if not tasks:
|
|
328
366
|
hud_console.error(f"No tasks found in: {source}")
|
|
@@ -385,6 +423,25 @@ async def run_full_dataset(
|
|
|
385
423
|
if allowed_tools:
|
|
386
424
|
agent_config["allowed_tools"] = allowed_tools
|
|
387
425
|
|
|
426
|
+
elif agent_type == "litellm":
|
|
427
|
+
try:
|
|
428
|
+
from hud.agents.lite_llm import LiteAgent
|
|
429
|
+
|
|
430
|
+
agent_class = LiteAgent
|
|
431
|
+
except ImportError as e:
|
|
432
|
+
hud_console.error(
|
|
433
|
+
"LiteLLM agent dependencies are not installed. "
|
|
434
|
+
"Please install with: pip install 'hud-python[agent]'"
|
|
435
|
+
)
|
|
436
|
+
raise typer.Exit(1) from e
|
|
437
|
+
|
|
438
|
+
agent_config = {
|
|
439
|
+
"model_name": model or "gpt-4o-mini",
|
|
440
|
+
"verbose": verbose,
|
|
441
|
+
}
|
|
442
|
+
if allowed_tools:
|
|
443
|
+
agent_config["allowed_tools"] = allowed_tools
|
|
444
|
+
|
|
388
445
|
else:
|
|
389
446
|
try:
|
|
390
447
|
from hud.agents import ClaudeAgent
|
|
@@ -501,10 +558,10 @@ def eval_command(
|
|
|
501
558
|
"--full",
|
|
502
559
|
help="Run the entire dataset (omit for single-task debug mode)",
|
|
503
560
|
),
|
|
504
|
-
agent: Literal["claude", "openai", "vllm"] = typer.Option(
|
|
561
|
+
agent: Literal["claude", "openai", "vllm", "litellm"] = typer.Option(
|
|
505
562
|
"claude",
|
|
506
563
|
"--agent",
|
|
507
|
-
help="Agent backend to use (claude, openai,
|
|
564
|
+
help="Agent backend to use (claude, openai, vllm for local server, or litellm)",
|
|
508
565
|
),
|
|
509
566
|
model: str | None = typer.Option(
|
|
510
567
|
None,
|
|
@@ -546,6 +603,12 @@ def eval_command(
|
|
|
546
603
|
"--verbose",
|
|
547
604
|
help="Enable verbose output from the agent",
|
|
548
605
|
),
|
|
606
|
+
very_verbose: bool = typer.Option(
|
|
607
|
+
False,
|
|
608
|
+
"--very-verbose",
|
|
609
|
+
"-vv",
|
|
610
|
+
help="Enable debug-level logs for maximum visibility",
|
|
611
|
+
),
|
|
549
612
|
vllm_base_url: str | None = typer.Option(
|
|
550
613
|
None,
|
|
551
614
|
"--vllm-base-url",
|
|
@@ -595,17 +658,34 @@ def eval_command(
|
|
|
595
658
|
"""
|
|
596
659
|
from hud.settings import settings
|
|
597
660
|
|
|
661
|
+
if very_verbose:
|
|
662
|
+
logging.basicConfig(
|
|
663
|
+
level=logging.DEBUG,
|
|
664
|
+
format="%(asctime)s - %(name)s - %(message)s",
|
|
665
|
+
datefmt="%H:%M:%S",
|
|
666
|
+
)
|
|
667
|
+
logging.getLogger("hud.agents").setLevel(logging.DEBUG)
|
|
668
|
+
logging.getLogger("hud.agents.base").setLevel(logging.DEBUG)
|
|
669
|
+
elif verbose:
|
|
670
|
+
logging.basicConfig(
|
|
671
|
+
level=logging.INFO,
|
|
672
|
+
format="%(asctime)s - %(name)s - %(message)s",
|
|
673
|
+
datefmt="%H:%M:%S",
|
|
674
|
+
)
|
|
675
|
+
logging.getLogger("hud.agents").setLevel(logging.INFO)
|
|
676
|
+
logging.getLogger("hud.agents.base").setLevel(logging.INFO)
|
|
677
|
+
|
|
598
678
|
# Check for required API keys
|
|
599
679
|
if agent == "claude":
|
|
600
680
|
if not settings.anthropic_api_key:
|
|
601
681
|
hud_console.error("ANTHROPIC_API_KEY is required for Claude agent")
|
|
602
682
|
hud_console.info(
|
|
603
|
-
"Set it in your environment or
|
|
683
|
+
"Set it in your environment or run: hud set ANTHROPIC_API_KEY=your-key-here"
|
|
604
684
|
)
|
|
605
685
|
raise typer.Exit(1)
|
|
606
686
|
elif agent == "openai" and not settings.openai_api_key:
|
|
607
687
|
hud_console.error("OPENAI_API_KEY is required for OpenAI agent")
|
|
608
|
-
hud_console.info("Set it in your environment or
|
|
688
|
+
hud_console.info("Set it in your environment or run: hud set OPENAI_API_KEY=your-key-here")
|
|
609
689
|
raise typer.Exit(1)
|
|
610
690
|
elif agent == "vllm":
|
|
611
691
|
if model:
|
|
@@ -617,7 +697,8 @@ def eval_command(
|
|
|
617
697
|
# Check for HUD_API_KEY if using HUD services
|
|
618
698
|
if not settings.api_key:
|
|
619
699
|
hud_console.warning("HUD_API_KEY not set. Some features may be limited.")
|
|
620
|
-
hud_console.info("Get your API key at: https://
|
|
700
|
+
hud_console.info("Get your API key at: https://hud.so")
|
|
701
|
+
hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
|
|
621
702
|
|
|
622
703
|
# Parse allowed tools
|
|
623
704
|
allowed_tools_list = (
|
|
@@ -641,7 +722,7 @@ def eval_command(
|
|
|
641
722
|
parallel=parallel,
|
|
642
723
|
max_workers=max_workers,
|
|
643
724
|
max_concurrent_per_worker=max_concurrent_per_worker,
|
|
644
|
-
verbose=verbose,
|
|
725
|
+
verbose=very_verbose or verbose,
|
|
645
726
|
vllm_base_url=vllm_base_url,
|
|
646
727
|
group_size=group_size,
|
|
647
728
|
)
|
|
@@ -654,7 +735,7 @@ def eval_command(
|
|
|
654
735
|
model=model,
|
|
655
736
|
allowed_tools=allowed_tools_list,
|
|
656
737
|
max_steps=max_steps,
|
|
657
|
-
verbose=verbose,
|
|
738
|
+
verbose=very_verbose or verbose,
|
|
658
739
|
vllm_base_url=vllm_base_url,
|
|
659
740
|
group_size=group_size,
|
|
660
741
|
)
|
hud/cli/flows/tasks.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import logging
|
|
4
5
|
import re
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from typing import TYPE_CHECKING, Any
|
|
@@ -8,10 +9,9 @@ from typing import TYPE_CHECKING, Any
|
|
|
8
9
|
import typer
|
|
9
10
|
import yaml
|
|
10
11
|
|
|
11
|
-
from hud.cli.build import build_environment
|
|
12
12
|
from hud.cli.push import push_environment
|
|
13
13
|
from hud.cli.utils.docker import require_docker_running
|
|
14
|
-
from hud.cli.utils.
|
|
14
|
+
from hud.cli.utils.env_check import ensure_built, find_environment_dir
|
|
15
15
|
from hud.cli.utils.registry import extract_name_and_tag
|
|
16
16
|
from hud.utils.hud_console import hud_console
|
|
17
17
|
from hud.utils.tasks import load_tasks
|
|
@@ -20,6 +20,9 @@ if TYPE_CHECKING:
|
|
|
20
20
|
from hud.types import Task
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
23
26
|
def _is_remote_url(url: str) -> bool:
|
|
24
27
|
"""Match the remote url."""
|
|
25
28
|
# See if a url is a remote url
|
|
@@ -53,62 +56,6 @@ def _validate_tasks(tasks: list[Task]) -> bool:
|
|
|
53
56
|
return True
|
|
54
57
|
|
|
55
58
|
|
|
56
|
-
def _find_environment_dir(tasks_path: Path) -> Path | None:
|
|
57
|
-
"""Find the environment directory related to a tasks file.
|
|
58
|
-
|
|
59
|
-
Strategy:
|
|
60
|
-
- Prefer a directory containing hud.lock.yaml
|
|
61
|
-
- Fallback to a directory that looks like an environment (Dockerfile + pyproject.toml)
|
|
62
|
-
- Search the tasks file directory, CWD, and a couple of parents
|
|
63
|
-
"""
|
|
64
|
-
candidates: list[Path] = []
|
|
65
|
-
cwd = Path.cwd()
|
|
66
|
-
candidates.extend([tasks_path.parent, cwd])
|
|
67
|
-
|
|
68
|
-
# Add parents (up to 2 levels for each)
|
|
69
|
-
for base in list(candidates):
|
|
70
|
-
p = base
|
|
71
|
-
for _ in range(2):
|
|
72
|
-
p = p.parent
|
|
73
|
-
if p not in candidates:
|
|
74
|
-
candidates.append(p)
|
|
75
|
-
|
|
76
|
-
# Prefer those with hud.lock.yaml
|
|
77
|
-
for d in candidates:
|
|
78
|
-
if (d / "hud.lock.yaml").exists():
|
|
79
|
-
return d
|
|
80
|
-
|
|
81
|
-
# Otherwise, find a plausible environment dir
|
|
82
|
-
for d in candidates:
|
|
83
|
-
try:
|
|
84
|
-
if is_environment_directory(d):
|
|
85
|
-
return d
|
|
86
|
-
except Exception as e:
|
|
87
|
-
hud_console.debug(f"Skipping path {d}: {e}")
|
|
88
|
-
continue
|
|
89
|
-
|
|
90
|
-
return None
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _ensure_built(env_dir: Path) -> dict[str, Any]:
|
|
94
|
-
"""Ensure the environment is built and a lock file exists; return lock data."""
|
|
95
|
-
lock_path = env_dir / "hud.lock.yaml"
|
|
96
|
-
if not lock_path.exists():
|
|
97
|
-
hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
|
|
98
|
-
if not hud_console.confirm("Build the environment now (runs 'hud build')?", default=True):
|
|
99
|
-
raise typer.Exit(1)
|
|
100
|
-
# Check Docker availability before attempting a build
|
|
101
|
-
require_docker_running()
|
|
102
|
-
# Run build (non-interactive). If Docker isn't running, this will raise and stop the flow.
|
|
103
|
-
# Force linux/amd64 platform to ensure compatibility during RL flows.
|
|
104
|
-
build_environment(str(env_dir), platform="linux/amd64")
|
|
105
|
-
|
|
106
|
-
# Load lock file
|
|
107
|
-
with open(lock_path) as f:
|
|
108
|
-
lock_data = yaml.safe_load(f) or {}
|
|
109
|
-
return lock_data
|
|
110
|
-
|
|
111
|
-
|
|
112
59
|
def _ensure_pushed(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, Any]:
|
|
113
60
|
"""Ensure the environment is pushed to a registry; return updated lock data."""
|
|
114
61
|
pushed = bool(lock_data.get("push"))
|
|
@@ -153,44 +100,228 @@ def _derive_remote_image(lock_data: dict[str, Any]) -> str:
|
|
|
153
100
|
return f"{name}:{tag}"
|
|
154
101
|
|
|
155
102
|
|
|
103
|
+
def _extract_existing_images(tasks: list[Task]) -> set[str]:
|
|
104
|
+
"""Extract all Mcp-Image references from tasks."""
|
|
105
|
+
images = set()
|
|
106
|
+
|
|
107
|
+
def _extract_from_obj(obj: Any) -> None:
|
|
108
|
+
if isinstance(obj, dict):
|
|
109
|
+
# Check for Mcp-Image in headers
|
|
110
|
+
if "headers" in obj and isinstance(obj["headers"], dict):
|
|
111
|
+
mcp_image = obj["headers"].get("Mcp-Image")
|
|
112
|
+
if mcp_image:
|
|
113
|
+
images.add(mcp_image)
|
|
114
|
+
# Recursively check nested objects
|
|
115
|
+
for v in obj.values():
|
|
116
|
+
_extract_from_obj(v)
|
|
117
|
+
elif isinstance(obj, list):
|
|
118
|
+
for item in obj:
|
|
119
|
+
_extract_from_obj(item)
|
|
120
|
+
|
|
121
|
+
for task in tasks:
|
|
122
|
+
if task.mcp_config:
|
|
123
|
+
_extract_from_obj(task.mcp_config)
|
|
124
|
+
|
|
125
|
+
return images
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _env_var_to_header_key(var_name: str) -> str:
|
|
129
|
+
"""Convert ENV_VAR style to Env-Env-Var header style.
|
|
130
|
+
|
|
131
|
+
Example: OPENAI_API_KEY -> Env-Openai-Api-Key
|
|
132
|
+
"""
|
|
133
|
+
parts = str(var_name).split("_")
|
|
134
|
+
return f"Env-{'-'.join(part.capitalize() for part in parts)}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extract_api_key_vars(lock_data: dict[str, Any]) -> set[str]:
|
|
138
|
+
"""Extract env var names from lock file's provided section (authoritative source).
|
|
139
|
+
|
|
140
|
+
We only use keys listed under environment.variables.provided, and exclude HUD_API_KEY
|
|
141
|
+
because Authorization already carries it.
|
|
142
|
+
"""
|
|
143
|
+
provided_keys: set[str] = set()
|
|
144
|
+
if not isinstance(lock_data, dict):
|
|
145
|
+
return provided_keys
|
|
146
|
+
try:
|
|
147
|
+
env_section = (lock_data.get("environment") or {}).get("variables") or {}
|
|
148
|
+
provided = env_section.get("provided") or {}
|
|
149
|
+
for name in provided:
|
|
150
|
+
provided_keys.add(str(name))
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.debug("Failed to parse provided env vars from lock data: %s", e)
|
|
153
|
+
provided_keys.discard("HUD_API_KEY")
|
|
154
|
+
return provided_keys
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _extract_dotenv_api_key_vars(env_dir: Path) -> set[str]:
|
|
158
|
+
"""Parse .env for API-like variables to suggest as headers.
|
|
159
|
+
|
|
160
|
+
We intentionally include only keys that look like secrets to avoid noise:
|
|
161
|
+
any key containing one of: api, key, token, secret, password (case-insensitive).
|
|
162
|
+
"""
|
|
163
|
+
dotenv_path = env_dir / ".env"
|
|
164
|
+
detected: set[str] = set()
|
|
165
|
+
if not dotenv_path.exists():
|
|
166
|
+
return detected
|
|
167
|
+
try:
|
|
168
|
+
for line in dotenv_path.read_text(encoding="utf-8").splitlines():
|
|
169
|
+
line = line.strip()
|
|
170
|
+
if not line or line.startswith("#"):
|
|
171
|
+
continue
|
|
172
|
+
if "=" not in line:
|
|
173
|
+
continue
|
|
174
|
+
name, _ = line.split("=", 1)
|
|
175
|
+
name = name.strip()
|
|
176
|
+
lowered = name.lower()
|
|
177
|
+
if any(s in lowered for s in ("api", "key", "token", "secret", "password")):
|
|
178
|
+
detected.add(name)
|
|
179
|
+
except Exception:
|
|
180
|
+
# Best-effort only
|
|
181
|
+
return detected
|
|
182
|
+
detected.discard("HUD_API_KEY")
|
|
183
|
+
return detected
|
|
184
|
+
|
|
185
|
+
|
|
156
186
|
def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
157
187
|
"""Convert a local tasks file to remote MCP tasks and return new filename.
|
|
158
188
|
|
|
159
189
|
Steps:
|
|
160
190
|
1) Find env dir; ensure built (hud.lock.yaml), otherwise build
|
|
161
191
|
2) Ensure pushed to registry, otherwise push
|
|
162
|
-
3)
|
|
163
|
-
4)
|
|
192
|
+
3) Check for outdated images in existing task configurations
|
|
193
|
+
4) Create remote_[tasks].json with mcp_config pointing to mcp.hud.so and Mcp-Image
|
|
194
|
+
5) Return the new tasks file path
|
|
164
195
|
"""
|
|
165
196
|
tasks_path = Path(tasks_file).resolve()
|
|
166
197
|
|
|
167
|
-
tasks
|
|
198
|
+
# Load validated tasks for decision-making (may resolve env vars)
|
|
199
|
+
tasks: list[Task] = load_tasks(str(tasks_path)) # type: ignore[assignment]
|
|
200
|
+
|
|
201
|
+
# Load raw tasks to preserve placeholders when writing back to disk
|
|
202
|
+
raw_tasks: list[dict[str, Any]] = load_tasks(str(tasks_path), raw=True) # type: ignore[assignment]
|
|
168
203
|
|
|
169
204
|
# Ensure HUD_API_KEY is available: prefer process env, else load from env_dir/.env
|
|
170
205
|
from hud.settings import settings
|
|
171
206
|
|
|
172
207
|
if not settings.api_key or not settings.api_key.strip():
|
|
173
208
|
hud_console.error("HUD_API_KEY is not set")
|
|
209
|
+
hud_console.info("Set it in your environment or run: hud set HUD_API_KEY=your-key-here")
|
|
174
210
|
raise typer.Exit(1)
|
|
175
211
|
|
|
212
|
+
# Check if tasks already have remote URLs
|
|
213
|
+
already_remote = _validate_tasks(tasks)
|
|
214
|
+
|
|
215
|
+
# Extract existing images from tasks
|
|
216
|
+
existing_images = _extract_existing_images(tasks)
|
|
217
|
+
|
|
176
218
|
# Load tasks (supports .json and .jsonl)
|
|
177
|
-
if
|
|
219
|
+
if already_remote and not existing_images:
|
|
220
|
+
# Tasks are remote but have no image references - just return as-is
|
|
178
221
|
return str(tasks_path)
|
|
179
222
|
|
|
180
223
|
# Locate environment
|
|
181
|
-
env_dir =
|
|
224
|
+
env_dir = find_environment_dir(tasks_path)
|
|
182
225
|
if not env_dir:
|
|
183
226
|
hud_console.error("Could not locate an environment directory (Dockerfile + pyproject.toml)")
|
|
184
227
|
hud_console.hint("Ensure you're in or near your environment folder before running 'hud rl'")
|
|
185
228
|
raise typer.Exit(1)
|
|
186
229
|
|
|
187
230
|
# Ensure built and pushed
|
|
188
|
-
lock_data =
|
|
231
|
+
lock_data = ensure_built(env_dir, interactive=True)
|
|
189
232
|
lock_data = _ensure_pushed(env_dir, lock_data)
|
|
190
233
|
|
|
191
234
|
# Derive remote image name org/name:tag
|
|
192
235
|
remote_image = _derive_remote_image(lock_data)
|
|
193
236
|
|
|
237
|
+
# Check if existing images are outdated
|
|
238
|
+
needs_update = False
|
|
239
|
+
should_update_image = False
|
|
240
|
+
if existing_images:
|
|
241
|
+
# Check if any existing image differs from the latest
|
|
242
|
+
for existing_img in existing_images:
|
|
243
|
+
if existing_img != remote_image:
|
|
244
|
+
hud_console.warning(f"Detected outdated image reference: {existing_img}")
|
|
245
|
+
hud_console.info(f"Latest pushed image: {remote_image}")
|
|
246
|
+
needs_update = True
|
|
247
|
+
break
|
|
248
|
+
|
|
249
|
+
if needs_update:
|
|
250
|
+
confirm_msg = "Update task configuration with the latest image?"
|
|
251
|
+
if hud_console.confirm(confirm_msg, default=True):
|
|
252
|
+
hud_console.info("Updating task configuration with latest image...")
|
|
253
|
+
should_update_image = True
|
|
254
|
+
else:
|
|
255
|
+
# If user doesn't want to update, just return the original file
|
|
256
|
+
if already_remote:
|
|
257
|
+
return str(tasks_path)
|
|
258
|
+
# Otherwise, continue with conversion but keep old images
|
|
259
|
+
remote_image = next(iter(existing_images)) # Use the first existing image
|
|
260
|
+
|
|
261
|
+
# If tasks are already remote and up-to-date (no update needed), return original file
|
|
262
|
+
if already_remote and not needs_update:
|
|
263
|
+
return str(tasks_path)
|
|
264
|
+
|
|
265
|
+
# If tasks are already remote and we just need to update the image
|
|
266
|
+
if already_remote and should_update_image:
|
|
267
|
+
# Update image references in-place on RAW tasks (preserve placeholders)
|
|
268
|
+
def _update_image_refs_raw(obj: Any) -> Any:
|
|
269
|
+
if isinstance(obj, dict):
|
|
270
|
+
new_obj = {}
|
|
271
|
+
for k, v in obj.items():
|
|
272
|
+
if k == "Mcp-Image" and isinstance(v, str) and v in existing_images:
|
|
273
|
+
new_obj[k] = remote_image
|
|
274
|
+
else:
|
|
275
|
+
new_obj[k] = _update_image_refs_raw(v)
|
|
276
|
+
return new_obj
|
|
277
|
+
elif isinstance(obj, list):
|
|
278
|
+
return [_update_image_refs_raw(item) for item in obj]
|
|
279
|
+
else:
|
|
280
|
+
return obj
|
|
281
|
+
|
|
282
|
+
updated_raw_tasks: list[dict[str, Any]] = []
|
|
283
|
+
for t in raw_tasks:
|
|
284
|
+
td = dict(t)
|
|
285
|
+
if "mcp_config" in td:
|
|
286
|
+
td["mcp_config"] = _update_image_refs_raw(td["mcp_config"])
|
|
287
|
+
updated_raw_tasks.append(td)
|
|
288
|
+
|
|
289
|
+
# Write updated file (preserve original format - check if it's .jsonl)
|
|
290
|
+
if tasks_path.suffix == ".jsonl":
|
|
291
|
+
with open(tasks_path, "w", encoding="utf-8") as f:
|
|
292
|
+
for task in updated_raw_tasks:
|
|
293
|
+
json.dump(task, f, ensure_ascii=False)
|
|
294
|
+
f.write("\n")
|
|
295
|
+
else:
|
|
296
|
+
with open(tasks_path, "w", encoding="utf-8") as f:
|
|
297
|
+
json.dump(updated_raw_tasks, f, ensure_ascii=False, indent=2)
|
|
298
|
+
f.write("\n")
|
|
299
|
+
|
|
300
|
+
hud_console.success(f"Updated {tasks_path.name} with latest image: {remote_image}")
|
|
301
|
+
return str(tasks_path)
|
|
302
|
+
|
|
303
|
+
# Extract additional API key headers from lock and suggest from .env
|
|
304
|
+
provided_keys = _extract_api_key_vars(lock_data)
|
|
305
|
+
dotenv_keys = _extract_dotenv_api_key_vars(env_dir)
|
|
306
|
+
|
|
307
|
+
# If .env contains API-like vars not in lock, offer to include them
|
|
308
|
+
missing = sorted(dotenv_keys - provided_keys)
|
|
309
|
+
if missing:
|
|
310
|
+
names_preview = ", ".join(missing)
|
|
311
|
+
prompt = (
|
|
312
|
+
f"Detected env vars in .env that look like API keys: {names_preview}.\n"
|
|
313
|
+
"Include them as remote headers (values will be ${VAR} placeholders)?"
|
|
314
|
+
)
|
|
315
|
+
if hud_console.confirm(prompt, default=True):
|
|
316
|
+
provided_keys.update(missing)
|
|
317
|
+
|
|
318
|
+
extra_api_key_headers: dict[str, str] = {}
|
|
319
|
+
for var_name in provided_keys:
|
|
320
|
+
if str(var_name).upper() == "HUD_API_KEY":
|
|
321
|
+
continue
|
|
322
|
+
header_key = _env_var_to_header_key(var_name)
|
|
323
|
+
extra_api_key_headers[header_key] = f"${{{var_name}}}"
|
|
324
|
+
|
|
194
325
|
# Helper to strip extra fields from tool calls
|
|
195
326
|
def _simplify_tool_call(tool: Any) -> Any:
|
|
196
327
|
def _one(x: Any) -> dict[str, Any]:
|
|
@@ -228,6 +359,9 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
|
228
359
|
},
|
|
229
360
|
}
|
|
230
361
|
|
|
362
|
+
# Merge additional API key headers
|
|
363
|
+
item["mcp_config"]["hud"]["headers"].update(extra_api_key_headers)
|
|
364
|
+
|
|
231
365
|
# Optional fields, omit Nones
|
|
232
366
|
if t.setup_tool is not None:
|
|
233
367
|
item["setup_tool"] = _simplify_tool_call(t.setup_tool)
|
|
@@ -242,7 +376,6 @@ def convert_tasks_to_remote(tasks_file: str) -> str:
|
|
|
242
376
|
|
|
243
377
|
tasks_payload.append(item)
|
|
244
378
|
|
|
245
|
-
# Write new file: remote_<name>.json (always JSON array)
|
|
246
379
|
remote_name = f"remote_{tasks_path.stem}.json"
|
|
247
380
|
remote_path = tasks_path.parent / remote_name
|
|
248
381
|
with open(remote_path, "w", encoding="utf-8") as f:
|