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.
@@ -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