valkyrie-sdk 0.1.0__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.
- valkyrie/sdk/__init__.py +48 -0
- valkyrie/sdk/client.py +120 -0
- valkyrie/sdk/config.py +121 -0
- valkyrie/sdk/errors.py +32 -0
- valkyrie/sdk/models/__init__.py +54 -0
- valkyrie/sdk/models/_base.py +20 -0
- valkyrie/sdk/models/agents.py +77 -0
- valkyrie/sdk/models/config.py +22 -0
- valkyrie/sdk/models/runs.py +249 -0
- valkyrie/sdk/py.typed +0 -0
- valkyrie/sdk/resources/__init__.py +5 -0
- valkyrie/sdk/resources/runs.py +340 -0
- valkyrie_sdk-0.1.0.dist-info/METADATA +29 -0
- valkyrie_sdk-0.1.0.dist-info/RECORD +16 -0
- valkyrie_sdk-0.1.0.dist-info/WHEEL +4 -0
- valkyrie_sdk-0.1.0.dist-info/licenses/LICENSE +661 -0
valkyrie/sdk/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Public interface for the async Valkyrie SDK."""
|
|
2
|
+
|
|
3
|
+
from valkyrie.sdk.models import (
|
|
4
|
+
AgentContractRequest,
|
|
5
|
+
BenchmarkStatus,
|
|
6
|
+
FetchBenchmarkResponse,
|
|
7
|
+
FetchBenchmarksRequest,
|
|
8
|
+
FetchBenchmarksResponse,
|
|
9
|
+
FinalViewResponse,
|
|
10
|
+
RetryMode,
|
|
11
|
+
RetryOrResumeBenchmarkResponse,
|
|
12
|
+
S3UploadResultsResponse,
|
|
13
|
+
StartBenchmarkResponse,
|
|
14
|
+
StopBenchmarkResponse,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from valkyrie.sdk.client import ValkyrieClient
|
|
18
|
+
from valkyrie.sdk.config import ValkyrieConfig
|
|
19
|
+
from valkyrie.sdk.errors import (
|
|
20
|
+
ValkyrieAPIError,
|
|
21
|
+
ValkyrieConfigError,
|
|
22
|
+
ValkyrieRunError,
|
|
23
|
+
ValkyrieSDKError,
|
|
24
|
+
ValkyrieStreamError,
|
|
25
|
+
ValkyrieTransportError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentContractRequest",
|
|
30
|
+
"BenchmarkStatus",
|
|
31
|
+
"FetchBenchmarkResponse",
|
|
32
|
+
"FetchBenchmarksRequest",
|
|
33
|
+
"FetchBenchmarksResponse",
|
|
34
|
+
"FinalViewResponse",
|
|
35
|
+
"RetryMode",
|
|
36
|
+
"RetryOrResumeBenchmarkResponse",
|
|
37
|
+
"S3UploadResultsResponse",
|
|
38
|
+
"StartBenchmarkResponse",
|
|
39
|
+
"StopBenchmarkResponse",
|
|
40
|
+
"ValkyrieAPIError",
|
|
41
|
+
"ValkyrieClient",
|
|
42
|
+
"ValkyrieConfig",
|
|
43
|
+
"ValkyrieConfigError",
|
|
44
|
+
"ValkyrieRunError",
|
|
45
|
+
"ValkyrieSDKError",
|
|
46
|
+
"ValkyrieStreamError",
|
|
47
|
+
"ValkyrieTransportError",
|
|
48
|
+
]
|
valkyrie/sdk/client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Async client for the Valkyrie API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from contextlib import AbstractAsyncContextManager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from valkyrie.sdk.config import DEFAULT_CONFIG_PATH, ValkyrieConfig
|
|
15
|
+
from valkyrie.sdk.errors import ValkyrieAPIError, ValkyrieTransportError
|
|
16
|
+
from valkyrie.sdk.resources.runs import RunsResource
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://benchmark-tracker.vals.ai"
|
|
19
|
+
ResponseModel = TypeVar("ResponseModel", bound=BaseModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValkyrieClient:
|
|
23
|
+
"""Async client for Valkyrie API resources."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
config: ValkyrieConfig,
|
|
28
|
+
*,
|
|
29
|
+
base_url: str | None = None,
|
|
30
|
+
timeout: float | httpx.Timeout = 120,
|
|
31
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self.config = config
|
|
34
|
+
resolved_base_url = base_url or os.environ.get("TRACKER_SERVICE_URL", DEFAULT_BASE_URL)
|
|
35
|
+
self._timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
36
|
+
self._client = httpx.AsyncClient(
|
|
37
|
+
base_url=resolved_base_url.rstrip("/"),
|
|
38
|
+
timeout=self._timeout,
|
|
39
|
+
headers=config.request_headers(),
|
|
40
|
+
transport=transport,
|
|
41
|
+
)
|
|
42
|
+
self.runs = RunsResource(self)
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_config(
|
|
46
|
+
cls,
|
|
47
|
+
path: str | Path = DEFAULT_CONFIG_PATH,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str | None = None,
|
|
50
|
+
timeout: float | httpx.Timeout = 120,
|
|
51
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
52
|
+
) -> ValkyrieClient:
|
|
53
|
+
"""Load a client from a CLI-compatible YAML config."""
|
|
54
|
+
return cls(
|
|
55
|
+
ValkyrieConfig.from_yaml(path),
|
|
56
|
+
base_url=base_url,
|
|
57
|
+
timeout=timeout,
|
|
58
|
+
transport=transport,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async def __aenter__(self) -> ValkyrieClient:
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
async def __aexit__(
|
|
65
|
+
self,
|
|
66
|
+
exc_type: type[BaseException] | None,
|
|
67
|
+
exc_value: BaseException | None,
|
|
68
|
+
traceback: TracebackType | None,
|
|
69
|
+
) -> None:
|
|
70
|
+
await self.close()
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
"""Close the underlying HTTP connection pool."""
|
|
74
|
+
await self._client.aclose()
|
|
75
|
+
|
|
76
|
+
async def request_model(
|
|
77
|
+
self,
|
|
78
|
+
method: str,
|
|
79
|
+
path: str,
|
|
80
|
+
model: type[ResponseModel],
|
|
81
|
+
*,
|
|
82
|
+
params: dict[str, Any] | None = None,
|
|
83
|
+
json: Any = None,
|
|
84
|
+
) -> ResponseModel:
|
|
85
|
+
"""Send a request and validate its response as a Pydantic model."""
|
|
86
|
+
try:
|
|
87
|
+
response = await self._client.request(method, path, params=params, json=json)
|
|
88
|
+
except httpx.HTTPError as exc:
|
|
89
|
+
raise ValkyrieTransportError(f"Valkyrie request failed: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
self.raise_for_status(response)
|
|
92
|
+
return model.model_validate(response.json())
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def raise_for_status(response: httpx.Response) -> None:
|
|
96
|
+
"""Convert a non-success response into an SDK API error."""
|
|
97
|
+
if response.is_success:
|
|
98
|
+
return
|
|
99
|
+
try:
|
|
100
|
+
body: Any = response.json()
|
|
101
|
+
except ValueError:
|
|
102
|
+
body = response.text
|
|
103
|
+
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
104
|
+
raise ValkyrieAPIError(response.status_code, detail)
|
|
105
|
+
|
|
106
|
+
def stream_response(
|
|
107
|
+
self,
|
|
108
|
+
method: str,
|
|
109
|
+
path: str,
|
|
110
|
+
*,
|
|
111
|
+
params: dict[str, Any] | None = None,
|
|
112
|
+
) -> AbstractAsyncContextManager[httpx.Response]:
|
|
113
|
+
"""Open a streaming response without a read timeout."""
|
|
114
|
+
timeout = httpx.Timeout(
|
|
115
|
+
connect=self._timeout.connect,
|
|
116
|
+
read=None,
|
|
117
|
+
write=self._timeout.write,
|
|
118
|
+
pool=self._timeout.pool,
|
|
119
|
+
)
|
|
120
|
+
return self._client.stream(method, path, params=params, timeout=timeout)
|
valkyrie/sdk/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Typed configuration for the Valkyrie SDK."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import TypeVar, cast
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
|
|
8
|
+
|
|
9
|
+
from valkyrie.sdk.errors import ValkyrieConfigError
|
|
10
|
+
from valkyrie.sdk.models import AWSCredentials, HarnessConfig
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATH = Path("~/.config/valkyrie/valkyrie.yaml")
|
|
13
|
+
ConfigT = TypeVar("ConfigT", bound="ValkyrieConfig")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ValkyrieConfig(BaseModel):
|
|
17
|
+
"""Validated SDK configuration using ``valkyrie.yaml`` field aliases."""
|
|
18
|
+
|
|
19
|
+
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
|
20
|
+
|
|
21
|
+
api_key: SecretStr | None = Field(default=None, repr=False)
|
|
22
|
+
aws_access_key_id: SecretStr = Field(alias="AWS_ACCESS_KEY_ID", repr=False)
|
|
23
|
+
aws_secret_access_key: SecretStr = Field(alias="AWS_SECRET_ACCESS_KEY", repr=False)
|
|
24
|
+
aws_default_region: str = Field(alias="AWS_DEFAULT_REGION")
|
|
25
|
+
aws_session_token: SecretStr | None = Field(default=None, alias="AWS_SESSION_TOKEN", repr=False)
|
|
26
|
+
s3_bucket: str = Field(alias="S3_BUCKET")
|
|
27
|
+
log_group: str = Field(default="benchmarks", alias="LOG_GROUP")
|
|
28
|
+
log_retention_policy: int = Field(default=365, alias="LOG_RETENTION_POLICY", gt=0)
|
|
29
|
+
sandbox_providers: dict[str, str] = Field(min_length=1, repr=False)
|
|
30
|
+
default_sandbox_provider: str | None = None
|
|
31
|
+
custom_benchmark_services: dict[str, str] = Field(default_factory=dict)
|
|
32
|
+
benchmark_auth: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
|
|
33
|
+
webhook: str | None = Field(default=None, repr=False)
|
|
34
|
+
|
|
35
|
+
@field_validator(
|
|
36
|
+
"aws_default_region",
|
|
37
|
+
"s3_bucket",
|
|
38
|
+
"log_group",
|
|
39
|
+
)
|
|
40
|
+
@classmethod
|
|
41
|
+
def reject_blank_required_values(cls, value: str) -> str:
|
|
42
|
+
"""Reject blank required values."""
|
|
43
|
+
if not value.strip():
|
|
44
|
+
raise ValueError("must not be blank")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
@field_validator("aws_access_key_id", "aws_secret_access_key")
|
|
48
|
+
@classmethod
|
|
49
|
+
def reject_blank_required_secrets(cls, value: SecretStr) -> SecretStr:
|
|
50
|
+
"""Reject blank required secret values."""
|
|
51
|
+
if not value.get_secret_value().strip():
|
|
52
|
+
raise ValueError("must not be blank")
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
@field_validator("custom_benchmark_services")
|
|
56
|
+
@classmethod
|
|
57
|
+
def normalize_service_urls(cls, services: dict[str, str]) -> dict[str, str]:
|
|
58
|
+
"""Remove trailing slashes from service URLs."""
|
|
59
|
+
return {name: url.rstrip("/") for name, url in services.items()}
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_yaml(cls: type[ConfigT], path: str | Path = DEFAULT_CONFIG_PATH) -> ConfigT:
|
|
63
|
+
"""Load and validate a CLI-compatible YAML config."""
|
|
64
|
+
config_path = Path(path).expanduser()
|
|
65
|
+
try:
|
|
66
|
+
with config_path.open(encoding="utf-8") as config_file:
|
|
67
|
+
raw_config = cast(object, yaml.safe_load(config_file))
|
|
68
|
+
except OSError as exc:
|
|
69
|
+
raise ValkyrieConfigError(f"Could not read Valkyrie config at {config_path}: {exc}") from exc
|
|
70
|
+
except yaml.YAMLError as exc:
|
|
71
|
+
raise ValkyrieConfigError(f"Invalid YAML in Valkyrie config at {config_path}: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
if not isinstance(raw_config, dict):
|
|
74
|
+
raise ValkyrieConfigError(f"Valkyrie config at {config_path} must contain a YAML mapping")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
return cls.model_validate(cast(dict[str, object], raw_config))
|
|
78
|
+
except ValidationError as exc:
|
|
79
|
+
raise ValkyrieConfigError(f"Invalid Valkyrie config at {config_path}: {exc}") from exc
|
|
80
|
+
|
|
81
|
+
def resolve_sandbox_provider(self, provider: str | None = None) -> tuple[str, str]:
|
|
82
|
+
"""Resolve the selected sandbox provider and secret name."""
|
|
83
|
+
provider_name = provider or self.default_sandbox_provider or next(iter(self.sandbox_providers))
|
|
84
|
+
secret_name = self.sandbox_providers.get(provider_name)
|
|
85
|
+
if secret_name is None:
|
|
86
|
+
configured = ", ".join(self.sandbox_providers)
|
|
87
|
+
raise ValkyrieConfigError(f"Unknown sandbox provider '{provider_name}'. Configured providers: {configured}")
|
|
88
|
+
return provider_name, secret_name
|
|
89
|
+
|
|
90
|
+
def harness_config(self, provider_secret_name: str) -> HarnessConfig:
|
|
91
|
+
"""Build the nested harness config expected by the tracker."""
|
|
92
|
+
return HarnessConfig(
|
|
93
|
+
aws=AWSCredentials(
|
|
94
|
+
aws_access_key_id=self.aws_access_key_id.get_secret_value(),
|
|
95
|
+
aws_secret_access_key=self.aws_secret_access_key.get_secret_value(),
|
|
96
|
+
aws_default_region=self.aws_default_region,
|
|
97
|
+
aws_session_token=(self.aws_session_token.get_secret_value() if self.aws_session_token else None),
|
|
98
|
+
),
|
|
99
|
+
s3_bucket=self.s3_bucket,
|
|
100
|
+
log_group=self.log_group,
|
|
101
|
+
log_retention_policy=self.log_retention_policy,
|
|
102
|
+
sandbox_provider_secret_name=provider_secret_name,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def request_headers(self) -> dict[str, str]:
|
|
106
|
+
"""Build API-key and harness headers for tracker requests."""
|
|
107
|
+
values: dict[str, str | None] = {
|
|
108
|
+
"AWS_ACCESS_KEY_ID": self.aws_access_key_id.get_secret_value(),
|
|
109
|
+
"AWS_SECRET_ACCESS_KEY": self.aws_secret_access_key.get_secret_value(),
|
|
110
|
+
"AWS_DEFAULT_REGION": self.aws_default_region,
|
|
111
|
+
"AWS_SESSION_TOKEN": self.aws_session_token.get_secret_value() if self.aws_session_token else None,
|
|
112
|
+
"S3_BUCKET": self.s3_bucket,
|
|
113
|
+
"LOG_GROUP": self.log_group,
|
|
114
|
+
"LOG_RETENTION_POLICY": str(self.log_retention_policy),
|
|
115
|
+
}
|
|
116
|
+
headers = {
|
|
117
|
+
f"X-Harness-{key.replace('_', '-').title()}": value for key, value in values.items() if value is not None
|
|
118
|
+
}
|
|
119
|
+
if self.api_key and (api_key := self.api_key.get_secret_value()):
|
|
120
|
+
headers["X-Api-Key"] = api_key
|
|
121
|
+
return headers
|
valkyrie/sdk/errors.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Exceptions raised by the Valkyrie SDK."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ValkyrieSDKError(Exception):
|
|
7
|
+
"""Base class for all SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ValkyrieConfigError(ValkyrieSDKError):
|
|
11
|
+
"""The supplied Valkyrie configuration is missing or invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ValkyrieRunError(ValkyrieSDKError):
|
|
15
|
+
"""A run operation received invalid client-side input."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ValkyrieTransportError(ValkyrieSDKError):
|
|
19
|
+
"""A request could not reach the Valkyrie service."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValkyrieAPIError(ValkyrieSDKError):
|
|
23
|
+
"""The Valkyrie API returned a non-success response."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, status_code: int, detail: Any):
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
self.detail = detail
|
|
28
|
+
super().__init__(f"Valkyrie API request failed ({status_code}): {detail}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ValkyrieStreamError(ValkyrieSDKError):
|
|
32
|
+
"""A run update stream returned an error or invalid event."""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Models owned by the standalone Valkyrie SDK."""
|
|
2
|
+
|
|
3
|
+
from valkyrie.sdk.models.agents import AgentContractRequest, OutputArtifact, OutputArtifactSpec
|
|
4
|
+
from valkyrie.sdk.models.config import AWSCredentials, HarnessConfig
|
|
5
|
+
from valkyrie.sdk.models.runs import (
|
|
6
|
+
AverageTaskBreakdown,
|
|
7
|
+
BenchmarkArguments,
|
|
8
|
+
BenchmarkDetails,
|
|
9
|
+
BenchmarkStatus,
|
|
10
|
+
BenchmarkTableRow,
|
|
11
|
+
DocentReadingStatus,
|
|
12
|
+
FetchBenchmarkResponse,
|
|
13
|
+
FetchBenchmarksRequest,
|
|
14
|
+
FetchBenchmarksResponse,
|
|
15
|
+
FinalEvaluation,
|
|
16
|
+
FinalViewResponse,
|
|
17
|
+
Order,
|
|
18
|
+
RetrieveResultsResponse,
|
|
19
|
+
RetryMode,
|
|
20
|
+
RetryOrResumeBenchmarkResponse,
|
|
21
|
+
S3UploadResultsResponse,
|
|
22
|
+
StartBenchmarkRequest,
|
|
23
|
+
StartBenchmarkResponse,
|
|
24
|
+
StopBenchmarkResponse,
|
|
25
|
+
TaskStatus,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AWSCredentials",
|
|
30
|
+
"AgentContractRequest",
|
|
31
|
+
"AverageTaskBreakdown",
|
|
32
|
+
"BenchmarkArguments",
|
|
33
|
+
"BenchmarkDetails",
|
|
34
|
+
"BenchmarkStatus",
|
|
35
|
+
"BenchmarkTableRow",
|
|
36
|
+
"DocentReadingStatus",
|
|
37
|
+
"FetchBenchmarkResponse",
|
|
38
|
+
"FetchBenchmarksRequest",
|
|
39
|
+
"FetchBenchmarksResponse",
|
|
40
|
+
"FinalEvaluation",
|
|
41
|
+
"FinalViewResponse",
|
|
42
|
+
"HarnessConfig",
|
|
43
|
+
"Order",
|
|
44
|
+
"OutputArtifact",
|
|
45
|
+
"OutputArtifactSpec",
|
|
46
|
+
"RetrieveResultsResponse",
|
|
47
|
+
"RetryMode",
|
|
48
|
+
"RetryOrResumeBenchmarkResponse",
|
|
49
|
+
"S3UploadResultsResponse",
|
|
50
|
+
"StartBenchmarkRequest",
|
|
51
|
+
"StartBenchmarkResponse",
|
|
52
|
+
"StopBenchmarkResponse",
|
|
53
|
+
"TaskStatus",
|
|
54
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Shared SDK model behavior."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ResponseModel(BaseModel):
|
|
9
|
+
"""Forward-compatible model for data returned by the Valkyrie API."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="ignore")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def serialize_utc(value: datetime | None) -> str | None:
|
|
15
|
+
"""Serialize datetimes with an explicit UTC offset."""
|
|
16
|
+
if value is None:
|
|
17
|
+
return None
|
|
18
|
+
if value.tzinfo is None:
|
|
19
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
20
|
+
return value.isoformat()
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Agent contract models used by SDK run requests."""
|
|
2
|
+
|
|
3
|
+
from pathlib import PurePosixPath
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
MAX_OUTPUT_ARTIFACT_COUNT = 10
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _source_has_glob(source: str) -> bool:
|
|
11
|
+
return any(char in source for char in "*?[")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _source_glob_root(source: str) -> str:
|
|
15
|
+
glob_indices = [source.find(char) for char in "*?[" if source.find(char) != -1]
|
|
16
|
+
first_glob_index = min(glob_indices)
|
|
17
|
+
return source[:first_glob_index].rsplit("/", 1)[0] or "/"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OutputArtifact(BaseModel):
|
|
21
|
+
"""One artifact copied from a sandbox after an agent run."""
|
|
22
|
+
|
|
23
|
+
path: str
|
|
24
|
+
source: str | None = None
|
|
25
|
+
|
|
26
|
+
@field_validator("source")
|
|
27
|
+
@classmethod
|
|
28
|
+
def validate_source(cls, value: str | None) -> str | None:
|
|
29
|
+
"""Validate an optional absolute sandbox source path."""
|
|
30
|
+
if not value:
|
|
31
|
+
return None
|
|
32
|
+
if not value.startswith("/"):
|
|
33
|
+
raise ValueError("output_artifacts source paths must be absolute sandbox paths")
|
|
34
|
+
|
|
35
|
+
path = PurePosixPath(value)
|
|
36
|
+
if not path.parts or ".." in path.parts or "." in path.parts:
|
|
37
|
+
raise ValueError("output_artifacts source paths cannot contain empty, '.', or '..' path parts")
|
|
38
|
+
if _source_has_glob(value) and _source_glob_root(value) == "/":
|
|
39
|
+
raise ValueError("output_artifacts glob sources must include a non-root directory prefix")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
OutputArtifactSpec = str | OutputArtifact
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AgentContractRequest(BaseModel):
|
|
47
|
+
"""Agent definition submitted when starting a run."""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
model: str | None = None
|
|
51
|
+
install_cmd: str = ""
|
|
52
|
+
run_cmd: str = ""
|
|
53
|
+
final_output: str | None = None
|
|
54
|
+
output_artifacts: list[OutputArtifactSpec] = Field(default_factory=list)
|
|
55
|
+
egress_allowlist: list[str] = Field(default_factory=list)
|
|
56
|
+
secrets: dict[str, str] = Field(default_factory=dict)
|
|
57
|
+
kwargs: dict[str, str] = Field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
@field_validator("output_artifacts")
|
|
60
|
+
@classmethod
|
|
61
|
+
def validate_output_artifacts(cls, value: list[OutputArtifactSpec]) -> list[OutputArtifactSpec]:
|
|
62
|
+
"""Validate and normalize artifact destination paths."""
|
|
63
|
+
if len(value) > MAX_OUTPUT_ARTIFACT_COUNT:
|
|
64
|
+
raise ValueError(f"output_artifacts cannot contain more than {MAX_OUTPUT_ARTIFACT_COUNT} entries")
|
|
65
|
+
|
|
66
|
+
normalized_artifacts: list[OutputArtifactSpec] = []
|
|
67
|
+
for artifact in value:
|
|
68
|
+
artifact_path = artifact if isinstance(artifact, str) else artifact.path
|
|
69
|
+
path = PurePosixPath(artifact_path)
|
|
70
|
+
if path.is_absolute():
|
|
71
|
+
raise ValueError("output_artifacts paths must be relative paths")
|
|
72
|
+
if not path.parts or ".." in path.parts or "." in path.parts:
|
|
73
|
+
raise ValueError("output_artifacts paths cannot contain empty, '.', or '..' path parts")
|
|
74
|
+
normalized_artifacts.append(
|
|
75
|
+
str(path) if isinstance(artifact, str) else artifact.model_copy(update={"path": str(path)})
|
|
76
|
+
)
|
|
77
|
+
return normalized_artifacts
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Nested configuration models sent to the Valkyrie API."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AWSCredentials(BaseModel, frozen=True):
|
|
7
|
+
"""AWS credentials forwarded in the run harness configuration."""
|
|
8
|
+
|
|
9
|
+
aws_access_key_id: str
|
|
10
|
+
aws_secret_access_key: str = Field(repr=False)
|
|
11
|
+
aws_default_region: str
|
|
12
|
+
aws_session_token: str | None = Field(default=None, repr=False)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HarnessConfig(BaseModel):
|
|
16
|
+
"""Harness configuration required to start a run."""
|
|
17
|
+
|
|
18
|
+
aws: AWSCredentials
|
|
19
|
+
s3_bucket: str
|
|
20
|
+
log_group: str
|
|
21
|
+
log_retention_policy: int
|
|
22
|
+
sandbox_provider_secret_name: str
|