hundredflags-sdk 0.4.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.
- ai_security_school_sdk/__init__.py +62 -0
- ai_security_school_sdk/async_client.py +23 -0
- ai_security_school_sdk/client.py +23 -0
- ai_security_school_sdk/errors.py +38 -0
- ai_security_school_sdk/models.py +26 -0
- ai_security_school_sdk/py.typed +0 -0
- hundredflags_sdk/__init__.py +49 -0
- hundredflags_sdk/_handles.py +34 -0
- hundredflags_sdk/_http.py +135 -0
- hundredflags_sdk/_schema.py +71 -0
- hundredflags_sdk/async_client.py +226 -0
- hundredflags_sdk/client.py +221 -0
- hundredflags_sdk/errors.py +89 -0
- hundredflags_sdk/models.py +64 -0
- hundredflags_sdk/py.typed +0 -0
- hundredflags_sdk-0.4.0.dist-info/METADATA +240 -0
- hundredflags_sdk-0.4.0.dist-info/RECORD +18 -0
- hundredflags_sdk-0.4.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Compatibility imports; use :mod:`hundredflags_sdk` for new code."""
|
|
2
|
+
|
|
3
|
+
from hundredflags_sdk import (
|
|
4
|
+
ActionDescriptor as ActionDescriptor,
|
|
5
|
+
)
|
|
6
|
+
from hundredflags_sdk import (
|
|
7
|
+
ActionValidationError as ActionValidationError,
|
|
8
|
+
)
|
|
9
|
+
from hundredflags_sdk import (
|
|
10
|
+
APIError as APIError,
|
|
11
|
+
)
|
|
12
|
+
from hundredflags_sdk import (
|
|
13
|
+
AsyncClient as AsyncClient,
|
|
14
|
+
)
|
|
15
|
+
from hundredflags_sdk import (
|
|
16
|
+
AuthenticationError as AuthenticationError,
|
|
17
|
+
)
|
|
18
|
+
from hundredflags_sdk import (
|
|
19
|
+
Client as Client,
|
|
20
|
+
)
|
|
21
|
+
from hundredflags_sdk import (
|
|
22
|
+
ConfigurationError as ConfigurationError,
|
|
23
|
+
)
|
|
24
|
+
from hundredflags_sdk import (
|
|
25
|
+
ConflictError as ConflictError,
|
|
26
|
+
)
|
|
27
|
+
from hundredflags_sdk import (
|
|
28
|
+
InstanceDocumentation as InstanceDocumentation,
|
|
29
|
+
)
|
|
30
|
+
from hundredflags_sdk import (
|
|
31
|
+
LimitExceededError as LimitExceededError,
|
|
32
|
+
)
|
|
33
|
+
from hundredflags_sdk import (
|
|
34
|
+
NotFoundError as NotFoundError,
|
|
35
|
+
)
|
|
36
|
+
from hundredflags_sdk import (
|
|
37
|
+
PermissionDeniedError as PermissionDeniedError,
|
|
38
|
+
)
|
|
39
|
+
from hundredflags_sdk import (
|
|
40
|
+
ProtocolError as ProtocolError,
|
|
41
|
+
)
|
|
42
|
+
from hundredflags_sdk import (
|
|
43
|
+
RuntimeResponse as RuntimeResponse,
|
|
44
|
+
)
|
|
45
|
+
from hundredflags_sdk import (
|
|
46
|
+
SDKError as SDKError,
|
|
47
|
+
)
|
|
48
|
+
from hundredflags_sdk import (
|
|
49
|
+
TaskDocumentation as TaskDocumentation,
|
|
50
|
+
)
|
|
51
|
+
from hundredflags_sdk import (
|
|
52
|
+
TaskSummary as TaskSummary,
|
|
53
|
+
)
|
|
54
|
+
from hundredflags_sdk import (
|
|
55
|
+
TransportError as TransportError,
|
|
56
|
+
)
|
|
57
|
+
from hundredflags_sdk import (
|
|
58
|
+
__all__ as __all__,
|
|
59
|
+
)
|
|
60
|
+
from hundredflags_sdk import (
|
|
61
|
+
__version__ as __version__,
|
|
62
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Compatibility imports; use :mod:`hundredflags_sdk.async_client` for new code."""
|
|
2
|
+
|
|
3
|
+
from hundredflags_sdk.async_client import (
|
|
4
|
+
AsyncActions as AsyncActions,
|
|
5
|
+
)
|
|
6
|
+
from hundredflags_sdk.async_client import (
|
|
7
|
+
AsyncClient as AsyncClient,
|
|
8
|
+
)
|
|
9
|
+
from hundredflags_sdk.async_client import (
|
|
10
|
+
AsyncEnvironmentResource as AsyncEnvironmentResource,
|
|
11
|
+
)
|
|
12
|
+
from hundredflags_sdk.async_client import (
|
|
13
|
+
AsyncEnvironments as AsyncEnvironments,
|
|
14
|
+
)
|
|
15
|
+
from hundredflags_sdk.async_client import (
|
|
16
|
+
AsyncEnvironmentTasks as AsyncEnvironmentTasks,
|
|
17
|
+
)
|
|
18
|
+
from hundredflags_sdk.async_client import (
|
|
19
|
+
AsyncTaskResource as AsyncTaskResource,
|
|
20
|
+
)
|
|
21
|
+
from hundredflags_sdk.async_client import (
|
|
22
|
+
AsyncTasks as AsyncTasks,
|
|
23
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Compatibility imports; use :mod:`hundredflags_sdk.client` for new code."""
|
|
2
|
+
|
|
3
|
+
from hundredflags_sdk.client import (
|
|
4
|
+
Actions as Actions,
|
|
5
|
+
)
|
|
6
|
+
from hundredflags_sdk.client import (
|
|
7
|
+
Client as Client,
|
|
8
|
+
)
|
|
9
|
+
from hundredflags_sdk.client import (
|
|
10
|
+
EnvironmentResource as EnvironmentResource,
|
|
11
|
+
)
|
|
12
|
+
from hundredflags_sdk.client import (
|
|
13
|
+
Environments as Environments,
|
|
14
|
+
)
|
|
15
|
+
from hundredflags_sdk.client import (
|
|
16
|
+
EnvironmentTasks as EnvironmentTasks,
|
|
17
|
+
)
|
|
18
|
+
from hundredflags_sdk.client import (
|
|
19
|
+
TaskResource as TaskResource,
|
|
20
|
+
)
|
|
21
|
+
from hundredflags_sdk.client import (
|
|
22
|
+
Tasks as Tasks,
|
|
23
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Compatibility imports; use :mod:`hundredflags_sdk.errors` for new code."""
|
|
2
|
+
|
|
3
|
+
from hundredflags_sdk.errors import (
|
|
4
|
+
ActionValidationError as ActionValidationError,
|
|
5
|
+
)
|
|
6
|
+
from hundredflags_sdk.errors import (
|
|
7
|
+
APIError as APIError,
|
|
8
|
+
)
|
|
9
|
+
from hundredflags_sdk.errors import (
|
|
10
|
+
AuthenticationError as AuthenticationError,
|
|
11
|
+
)
|
|
12
|
+
from hundredflags_sdk.errors import (
|
|
13
|
+
ConfigurationError as ConfigurationError,
|
|
14
|
+
)
|
|
15
|
+
from hundredflags_sdk.errors import (
|
|
16
|
+
ConflictError as ConflictError,
|
|
17
|
+
)
|
|
18
|
+
from hundredflags_sdk.errors import (
|
|
19
|
+
LimitExceededError as LimitExceededError,
|
|
20
|
+
)
|
|
21
|
+
from hundredflags_sdk.errors import (
|
|
22
|
+
NotFoundError as NotFoundError,
|
|
23
|
+
)
|
|
24
|
+
from hundredflags_sdk.errors import (
|
|
25
|
+
PermissionDeniedError as PermissionDeniedError,
|
|
26
|
+
)
|
|
27
|
+
from hundredflags_sdk.errors import (
|
|
28
|
+
ProtocolError as ProtocolError,
|
|
29
|
+
)
|
|
30
|
+
from hundredflags_sdk.errors import (
|
|
31
|
+
SDKError as SDKError,
|
|
32
|
+
)
|
|
33
|
+
from hundredflags_sdk.errors import (
|
|
34
|
+
TransportError as TransportError,
|
|
35
|
+
)
|
|
36
|
+
from hundredflags_sdk.errors import (
|
|
37
|
+
api_error as api_error,
|
|
38
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Compatibility imports; use :mod:`hundredflags_sdk.models` for new code."""
|
|
2
|
+
|
|
3
|
+
from hundredflags_sdk.models import (
|
|
4
|
+
ActionDescriptor as ActionDescriptor,
|
|
5
|
+
)
|
|
6
|
+
from hundredflags_sdk.models import (
|
|
7
|
+
InstanceDocumentation as InstanceDocumentation,
|
|
8
|
+
)
|
|
9
|
+
from hundredflags_sdk.models import (
|
|
10
|
+
InstanceList as InstanceList,
|
|
11
|
+
)
|
|
12
|
+
from hundredflags_sdk.models import (
|
|
13
|
+
JsonObject as JsonObject,
|
|
14
|
+
)
|
|
15
|
+
from hundredflags_sdk.models import (
|
|
16
|
+
PublicModel as PublicModel,
|
|
17
|
+
)
|
|
18
|
+
from hundredflags_sdk.models import (
|
|
19
|
+
RuntimeResponse as RuntimeResponse,
|
|
20
|
+
)
|
|
21
|
+
from hundredflags_sdk.models import (
|
|
22
|
+
TaskDocumentation as TaskDocumentation,
|
|
23
|
+
)
|
|
24
|
+
from hundredflags_sdk.models import (
|
|
25
|
+
TaskSummary as TaskSummary,
|
|
26
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""HundredFlags SDK: documentation and actions for existing agent environments."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as _package_version
|
|
4
|
+
|
|
5
|
+
from .async_client import AsyncClient
|
|
6
|
+
from .client import Client
|
|
7
|
+
from .errors import (
|
|
8
|
+
ActionValidationError,
|
|
9
|
+
APIError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
ConflictError,
|
|
13
|
+
LimitExceededError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
PermissionDeniedError,
|
|
16
|
+
ProtocolError,
|
|
17
|
+
SDKError,
|
|
18
|
+
TransportError,
|
|
19
|
+
)
|
|
20
|
+
from .models import (
|
|
21
|
+
ActionDescriptor,
|
|
22
|
+
InstanceDocumentation,
|
|
23
|
+
RuntimeResponse,
|
|
24
|
+
TaskDocumentation,
|
|
25
|
+
TaskSummary,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = _package_version("hundredflags-sdk")
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"APIError",
|
|
32
|
+
"ActionDescriptor",
|
|
33
|
+
"ActionValidationError",
|
|
34
|
+
"AsyncClient",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"Client",
|
|
37
|
+
"ConfigurationError",
|
|
38
|
+
"ConflictError",
|
|
39
|
+
"InstanceDocumentation",
|
|
40
|
+
"LimitExceededError",
|
|
41
|
+
"NotFoundError",
|
|
42
|
+
"PermissionDeniedError",
|
|
43
|
+
"ProtocolError",
|
|
44
|
+
"RuntimeResponse",
|
|
45
|
+
"SDKError",
|
|
46
|
+
"TaskDocumentation",
|
|
47
|
+
"TaskSummary",
|
|
48
|
+
"TransportError",
|
|
49
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared metadata properties; resources do not represent separate server sessions."""
|
|
2
|
+
|
|
3
|
+
from .models import InstanceDocumentation, TaskDocumentation, TaskSummary
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EnvironmentHandle:
|
|
7
|
+
def __init__(self, info: InstanceDocumentation) -> None:
|
|
8
|
+
self.info = info
|
|
9
|
+
|
|
10
|
+
@property
|
|
11
|
+
def instance_id(self) -> str:
|
|
12
|
+
return self.info.instance_id
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def title(self) -> str:
|
|
16
|
+
return self.info.title
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskHandle:
|
|
20
|
+
def __init__(self, info: TaskSummary | TaskDocumentation, instance_id: str) -> None:
|
|
21
|
+
self.info = info
|
|
22
|
+
self._instance_id = instance_id
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def task_id(self) -> str:
|
|
26
|
+
return self.info.task_id
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def instance_id(self) -> str:
|
|
30
|
+
return self._instance_id
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def title(self) -> str:
|
|
34
|
+
return self.info.title
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Transport policy and response parsing shared by synchronous and async clients."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigurationError, ProtocolError, api_error
|
|
14
|
+
from .models import JsonObject, PublicModel
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://plgn.hundredflags.ru"
|
|
17
|
+
API_PREFIX = "/api/agent-env/"
|
|
18
|
+
RETRYABLE_STATUS = {429, 502, 503, 504}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def identifier(value: str) -> str:
|
|
22
|
+
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]*", value):
|
|
23
|
+
raise ConfigurationError("Resource identifiers must contain only letters, digits, _ . : -")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def positive_duration(value: float, name: str, *, allow_zero: bool = False) -> float:
|
|
28
|
+
if not math.isfinite(value) or value < 0 or (value == 0 and not allow_zero):
|
|
29
|
+
raise ConfigurationError(
|
|
30
|
+
f"{name} must be finite and {'non-negative' if allow_zero else 'positive'}"
|
|
31
|
+
)
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def environment_config(overrides: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
options: dict[str, Any] = {
|
|
37
|
+
"token": os.environ.get(
|
|
38
|
+
"HUNDREDFLAGS_TOKEN", os.environ.get("AI_SECURITY_SCHOOL_TOKEN", "")
|
|
39
|
+
),
|
|
40
|
+
"base_url": os.environ.get(
|
|
41
|
+
"HUNDREDFLAGS_BASE_URL",
|
|
42
|
+
os.environ.get("AI_SECURITY_SCHOOL_BASE_URL") or DEFAULT_BASE_URL,
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
options.update(overrides)
|
|
46
|
+
return options
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def client_options(
|
|
50
|
+
token: str, base_url: str, timeout: float, max_retries: int, retry_backoff: float
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
if not token or token != token.strip() or any(ord(c) < 33 or ord(c) > 126 for c in token):
|
|
53
|
+
raise ConfigurationError("Set HUNDREDFLAGS_TOKEN to a valid learner token")
|
|
54
|
+
try:
|
|
55
|
+
url = urlsplit(base_url)
|
|
56
|
+
except ValueError as exc:
|
|
57
|
+
raise ConfigurationError("base_url must be a valid server URL") from exc
|
|
58
|
+
if (
|
|
59
|
+
url.scheme not in {"https", "http"}
|
|
60
|
+
or not url.netloc
|
|
61
|
+
or url.username is not None
|
|
62
|
+
or url.password is not None
|
|
63
|
+
or url.query
|
|
64
|
+
or url.fragment
|
|
65
|
+
or url.path.rstrip("/") not in {"", API_PREFIX.rstrip("/")}
|
|
66
|
+
):
|
|
67
|
+
raise ConfigurationError("base_url must be a server origin or agent-env API base URL")
|
|
68
|
+
if url.scheme == "http" and url.hostname not in {"localhost", "127.0.0.1", "::1", "testserver"}:
|
|
69
|
+
raise ConfigurationError(
|
|
70
|
+
"Remote servers require HTTPS; HTTP is supported only for local use"
|
|
71
|
+
)
|
|
72
|
+
positive_duration(timeout, "timeout")
|
|
73
|
+
positive_duration(retry_backoff, "retry_backoff", allow_zero=True)
|
|
74
|
+
if not isinstance(max_retries, int) or isinstance(max_retries, bool) or max_retries < 0:
|
|
75
|
+
raise ConfigurationError("max_retries must be a non-negative integer")
|
|
76
|
+
return {
|
|
77
|
+
"base_url": f"{url.scheme}://{url.netloc}{API_PREFIX}",
|
|
78
|
+
"headers": {
|
|
79
|
+
"Authorization": f"Bearer {token}",
|
|
80
|
+
"Accept": "application/json",
|
|
81
|
+
"User-Agent": f"hundredflags-sdk/{version('hundredflags-sdk')}",
|
|
82
|
+
},
|
|
83
|
+
"timeout": timeout,
|
|
84
|
+
"follow_redirects": False,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def retry_delay(response: httpx.Response | None, attempt: int, backoff: float) -> float:
|
|
89
|
+
if response is not None:
|
|
90
|
+
try:
|
|
91
|
+
delay = float(response.headers.get("Retry-After", ""))
|
|
92
|
+
if math.isfinite(delay) and delay >= 0:
|
|
93
|
+
return min(delay, 30.0)
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return min(math.ldexp(backoff, min(attempt, 10)), 30.0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def decode_response(response: httpx.Response) -> JsonObject:
|
|
100
|
+
if not 200 <= response.status_code < 300:
|
|
101
|
+
try:
|
|
102
|
+
body = response.json()
|
|
103
|
+
except ValueError:
|
|
104
|
+
body = {}
|
|
105
|
+
if not isinstance(body, dict):
|
|
106
|
+
body = {}
|
|
107
|
+
error = body.get("error")
|
|
108
|
+
error = error if isinstance(error, dict) else {}
|
|
109
|
+
code = error.get("code")
|
|
110
|
+
message = error.get("message")
|
|
111
|
+
details = error.get("details")
|
|
112
|
+
details = dict(details) if isinstance(details, dict) else {}
|
|
113
|
+
for key in ("usage", "retry_after"):
|
|
114
|
+
if key in body:
|
|
115
|
+
details[key] = body[key]
|
|
116
|
+
raise api_error(
|
|
117
|
+
code if isinstance(code, str) else "http_error",
|
|
118
|
+
message if isinstance(message, str) else f"Server returned HTTP {response.status_code}",
|
|
119
|
+
status_code=response.status_code,
|
|
120
|
+
details=details,
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
value = response.json()
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ProtocolError("Server returned invalid JSON") from exc
|
|
126
|
+
if not isinstance(value, dict):
|
|
127
|
+
raise ProtocolError("Expected a JSON object response")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_model[ModelT: PublicModel](model: type[ModelT], value: JsonObject) -> ModelT:
|
|
132
|
+
try:
|
|
133
|
+
return model.model_validate(value)
|
|
134
|
+
except ValidationError as exc:
|
|
135
|
+
raise ProtocolError(f"Server returned an invalid {model.__name__} response") from exc
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Local schema validation never resolves or downloads remote references."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jsonschema import Draft202012Validator
|
|
7
|
+
from jsonschema.exceptions import SchemaError
|
|
8
|
+
from referencing import Registry
|
|
9
|
+
|
|
10
|
+
from .errors import ActionValidationError, ProtocolError
|
|
11
|
+
from .models import JsonObject, TaskDocumentation
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _check_references(value: Any) -> None:
|
|
15
|
+
if isinstance(value, dict):
|
|
16
|
+
for key, child in value.items():
|
|
17
|
+
if key in {"$ref", "$dynamicRef"} and (
|
|
18
|
+
not isinstance(child, str) or not child.startswith("#")
|
|
19
|
+
):
|
|
20
|
+
raise ProtocolError("Action schemas may only use local fragment references")
|
|
21
|
+
_check_references(child)
|
|
22
|
+
elif isinstance(value, list):
|
|
23
|
+
for child in value:
|
|
24
|
+
_check_references(child)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def json_object(value: JsonObject) -> JsonObject:
|
|
28
|
+
if not isinstance(value, dict):
|
|
29
|
+
raise ActionValidationError("Action payload must be a JSON object")
|
|
30
|
+
try:
|
|
31
|
+
frozen: JsonObject = json.loads(json.dumps(value, allow_nan=False))
|
|
32
|
+
except (TypeError, ValueError, OverflowError, RecursionError) as exc:
|
|
33
|
+
raise ActionValidationError("Action payload must contain only finite JSON values") from exc
|
|
34
|
+
return frozen
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def validate_payload(
|
|
38
|
+
schema: JsonObject, payload: JsonObject, *, name: str = "action"
|
|
39
|
+
) -> JsonObject:
|
|
40
|
+
frozen = json_object(payload)
|
|
41
|
+
_check_references(schema)
|
|
42
|
+
try:
|
|
43
|
+
Draft202012Validator.check_schema(schema)
|
|
44
|
+
validator = Draft202012Validator(schema, registry=Registry())
|
|
45
|
+
violation = next(validator.iter_errors(frozen), None)
|
|
46
|
+
except SchemaError as exc:
|
|
47
|
+
raise ProtocolError("Server returned an invalid action input schema") from exc
|
|
48
|
+
except Exception as exc:
|
|
49
|
+
raise ProtocolError("Action input schema contains an unresolvable reference") from exc
|
|
50
|
+
if violation is not None:
|
|
51
|
+
path = list(violation.absolute_path)
|
|
52
|
+
location = ".".join(str(part) for part in path) or "payload"
|
|
53
|
+
raise ActionValidationError(
|
|
54
|
+
f"{name}: {location} fails {violation.validator} validation", path=path
|
|
55
|
+
)
|
|
56
|
+
return frozen
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def action_payload(
|
|
60
|
+
documentation: TaskDocumentation, name: str, arguments: JsonObject
|
|
61
|
+
) -> JsonObject:
|
|
62
|
+
descriptor = next((action for action in documentation.actions if action.name == name), None)
|
|
63
|
+
if descriptor is None:
|
|
64
|
+
raise ActionValidationError("This action is not documented for the task")
|
|
65
|
+
arguments = json_object(arguments)
|
|
66
|
+
if "action" in arguments:
|
|
67
|
+
raise ActionValidationError("Pass the action name separately, without arguments.action")
|
|
68
|
+
validated = validate_payload(descriptor.input_schema, arguments, name=name)
|
|
69
|
+
return validate_payload(
|
|
70
|
+
documentation.action_payload_schema, {"action": name, **validated}, name=name
|
|
71
|
+
)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Asynchronous client for the platform's shared agent-env runtime."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._handles import EnvironmentHandle, TaskHandle
|
|
10
|
+
from ._http import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
RETRYABLE_STATUS,
|
|
13
|
+
client_options,
|
|
14
|
+
decode_response,
|
|
15
|
+
environment_config,
|
|
16
|
+
identifier,
|
|
17
|
+
parse_model,
|
|
18
|
+
retry_delay,
|
|
19
|
+
)
|
|
20
|
+
from ._schema import action_payload, json_object, validate_payload
|
|
21
|
+
from .errors import ActionValidationError, ConfigurationError, ProtocolError, TransportError
|
|
22
|
+
from .models import (
|
|
23
|
+
ActionDescriptor,
|
|
24
|
+
InstanceDocumentation,
|
|
25
|
+
InstanceList,
|
|
26
|
+
JsonObject,
|
|
27
|
+
RuntimeResponse,
|
|
28
|
+
TaskDocumentation,
|
|
29
|
+
TaskSummary,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncClient:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
token: str,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
39
|
+
timeout: float = 120.0,
|
|
40
|
+
max_retries: int = 2,
|
|
41
|
+
retry_backoff: float = 0.25,
|
|
42
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
options = client_options(token, base_url, timeout, max_retries, retry_backoff)
|
|
45
|
+
self._http = httpx.AsyncClient(**options, transport=transport)
|
|
46
|
+
self._max_retries = max_retries
|
|
47
|
+
self._retry_backoff = retry_backoff
|
|
48
|
+
self.envs = AsyncEnvironments(self)
|
|
49
|
+
self.tasks = AsyncTasks(self)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(cls, **overrides: Any) -> Self:
|
|
53
|
+
return cls(**environment_config(overrides))
|
|
54
|
+
|
|
55
|
+
async def __aenter__(self) -> Self:
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
async def __aexit__(
|
|
59
|
+
self,
|
|
60
|
+
exc_type: type[BaseException] | None,
|
|
61
|
+
exc_value: BaseException | None,
|
|
62
|
+
traceback: TracebackType | None,
|
|
63
|
+
) -> None:
|
|
64
|
+
await self.close()
|
|
65
|
+
|
|
66
|
+
async def close(self) -> None:
|
|
67
|
+
await self._http.aclose()
|
|
68
|
+
|
|
69
|
+
async def _request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
body: JsonObject | None = None,
|
|
75
|
+
params: dict[str, str] | None = None,
|
|
76
|
+
) -> JsonObject:
|
|
77
|
+
# The shared runtime does not deduplicate mutations. Never retry a POST.
|
|
78
|
+
retries = self._max_retries if method == "GET" else 0
|
|
79
|
+
for attempt in range(retries + 1):
|
|
80
|
+
response: httpx.Response | None = None
|
|
81
|
+
try:
|
|
82
|
+
response = await self._http.request(method, path, json=body, params=params)
|
|
83
|
+
except httpx.TransportError as exc:
|
|
84
|
+
if attempt == retries:
|
|
85
|
+
raise TransportError(may_have_executed=method != "GET") from exc
|
|
86
|
+
else:
|
|
87
|
+
if response.status_code not in RETRYABLE_STATUS or attempt == retries:
|
|
88
|
+
return decode_response(response)
|
|
89
|
+
await asyncio.sleep(retry_delay(response, attempt, self._retry_backoff))
|
|
90
|
+
raise AssertionError("Unreachable retry state")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AsyncEnvironments:
|
|
94
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
95
|
+
self._client = client
|
|
96
|
+
|
|
97
|
+
async def get(self, instance_id: str) -> "AsyncEnvironmentResource":
|
|
98
|
+
data = await self._client._request("GET", f"instances/{identifier(instance_id)}")
|
|
99
|
+
info = parse_model(InstanceDocumentation, data)
|
|
100
|
+
if info.instance_id != instance_id:
|
|
101
|
+
raise ProtocolError("Server returned documentation for a different environment")
|
|
102
|
+
return AsyncEnvironmentResource(self._client, info)
|
|
103
|
+
|
|
104
|
+
async def list(self) -> list["AsyncEnvironmentResource"]:
|
|
105
|
+
data = parse_model(InstanceList, await self._client._request("GET", "instances"))
|
|
106
|
+
return [AsyncEnvironmentResource(self._client, info) for info in data.instances]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AsyncTasks:
|
|
110
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
111
|
+
self._client = client
|
|
112
|
+
|
|
113
|
+
async def get(self, task_id: str) -> "AsyncTaskResource":
|
|
114
|
+
data = await self._client._request("GET", f"tasks/{identifier(task_id)}/documentation")
|
|
115
|
+
info = parse_model(TaskDocumentation, data)
|
|
116
|
+
if info.task_id != task_id:
|
|
117
|
+
raise ProtocolError("Server returned documentation for a different task")
|
|
118
|
+
return AsyncTaskResource(self._client, info, info.instance_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class AsyncEnvironmentResource(EnvironmentHandle):
|
|
122
|
+
def __init__(self, client: AsyncClient, info: InstanceDocumentation) -> None:
|
|
123
|
+
super().__init__(info)
|
|
124
|
+
self.tasks = AsyncEnvironmentTasks(client, info)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AsyncEnvironmentTasks:
|
|
128
|
+
def __init__(self, client: AsyncClient, info: InstanceDocumentation) -> None:
|
|
129
|
+
self._client = client
|
|
130
|
+
self._info = info
|
|
131
|
+
|
|
132
|
+
async def list(self) -> list["AsyncTaskResource"]:
|
|
133
|
+
"""Return handles from the environment snapshot; load task schemas on use."""
|
|
134
|
+
return [
|
|
135
|
+
AsyncTaskResource(self._client, task, self._info.instance_id)
|
|
136
|
+
for task in self._info.tasks
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
async def get(self, task_id: str) -> "AsyncTaskResource":
|
|
140
|
+
if not any(task.task_id == task_id for task in self._info.tasks):
|
|
141
|
+
raise ConfigurationError("The task is not in this environment's documentation")
|
|
142
|
+
task = await self._client.tasks.get(task_id)
|
|
143
|
+
if task.instance_id != self._info.instance_id:
|
|
144
|
+
raise ProtocolError("Server returned documentation for a different environment")
|
|
145
|
+
return task
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncTaskResource(TaskHandle):
|
|
149
|
+
def __init__(
|
|
150
|
+
self, client: AsyncClient, info: TaskSummary | TaskDocumentation, instance_id: str
|
|
151
|
+
) -> None:
|
|
152
|
+
super().__init__(info, instance_id)
|
|
153
|
+
self._client = client
|
|
154
|
+
self.actions = AsyncActions(self)
|
|
155
|
+
|
|
156
|
+
async def documentation(self) -> TaskDocumentation:
|
|
157
|
+
"""Fetch current learner documentation and refresh this resource's metadata."""
|
|
158
|
+
data = await self._client._request("GET", f"tasks/{identifier(self.task_id)}/documentation")
|
|
159
|
+
info = parse_model(TaskDocumentation, data)
|
|
160
|
+
if info.task_id != self.task_id or info.instance_id != self.instance_id:
|
|
161
|
+
raise ProtocolError("Server returned documentation for a different task or environment")
|
|
162
|
+
self.info = info
|
|
163
|
+
return info
|
|
164
|
+
|
|
165
|
+
async def state(self) -> RuntimeResponse:
|
|
166
|
+
return parse_model(
|
|
167
|
+
RuntimeResponse,
|
|
168
|
+
await self._client._request(
|
|
169
|
+
"GET", "state", params={"task_id": identifier(self.task_id)}
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
async def act(self, payload: JsonObject) -> RuntimeResponse:
|
|
174
|
+
"""Send the task's native payload, including chat schemas without an action name."""
|
|
175
|
+
documentation = await self.documentation()
|
|
176
|
+
return await self._act(validate_payload(documentation.action_payload_schema, payload))
|
|
177
|
+
|
|
178
|
+
async def _act(self, payload: JsonObject) -> RuntimeResponse:
|
|
179
|
+
return parse_model(
|
|
180
|
+
RuntimeResponse,
|
|
181
|
+
await self._client._request(
|
|
182
|
+
"POST",
|
|
183
|
+
"action",
|
|
184
|
+
body={"task_id": identifier(self.task_id), "action_payload": payload},
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
async def grade(self, payload: JsonObject | None = None) -> RuntimeResponse:
|
|
189
|
+
validated = json_object(payload if payload is not None else {})
|
|
190
|
+
if not (await self.documentation()).supports_standalone_grading:
|
|
191
|
+
raise ActionValidationError(
|
|
192
|
+
"Standalone grading is unavailable; inspect the task's documented actions"
|
|
193
|
+
)
|
|
194
|
+
return parse_model(
|
|
195
|
+
RuntimeResponse,
|
|
196
|
+
await self._client._request(
|
|
197
|
+
"POST",
|
|
198
|
+
"grade",
|
|
199
|
+
body={
|
|
200
|
+
"task_id": identifier(self.task_id),
|
|
201
|
+
"payload": validated,
|
|
202
|
+
},
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
async def reset(self) -> RuntimeResponse:
|
|
207
|
+
"""Reset the user's shared environment through the task's existing reset behavior."""
|
|
208
|
+
return parse_model(
|
|
209
|
+
RuntimeResponse,
|
|
210
|
+
await self._client._request(
|
|
211
|
+
"POST", "reset", body={"task_id": identifier(self.task_id)}
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class AsyncActions:
|
|
217
|
+
def __init__(self, task: AsyncTaskResource) -> None:
|
|
218
|
+
self._task = task
|
|
219
|
+
|
|
220
|
+
async def list(self) -> list[ActionDescriptor]:
|
|
221
|
+
return (await self._task.documentation()).actions
|
|
222
|
+
|
|
223
|
+
async def call(self, name: str, arguments: JsonObject) -> RuntimeResponse:
|
|
224
|
+
"""Validate documented arguments and insert the native action discriminator."""
|
|
225
|
+
documentation = await self._task.documentation()
|
|
226
|
+
return await self._task._act(action_payload(documentation, name, arguments))
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Synchronous client for the platform's shared agent-env runtime."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
from typing import Any, Self
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._handles import EnvironmentHandle, TaskHandle
|
|
10
|
+
from ._http import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
RETRYABLE_STATUS,
|
|
13
|
+
client_options,
|
|
14
|
+
decode_response,
|
|
15
|
+
environment_config,
|
|
16
|
+
identifier,
|
|
17
|
+
parse_model,
|
|
18
|
+
retry_delay,
|
|
19
|
+
)
|
|
20
|
+
from ._schema import action_payload, json_object, validate_payload
|
|
21
|
+
from .errors import ActionValidationError, ConfigurationError, ProtocolError, TransportError
|
|
22
|
+
from .models import (
|
|
23
|
+
ActionDescriptor,
|
|
24
|
+
InstanceDocumentation,
|
|
25
|
+
InstanceList,
|
|
26
|
+
JsonObject,
|
|
27
|
+
RuntimeResponse,
|
|
28
|
+
TaskDocumentation,
|
|
29
|
+
TaskSummary,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Client:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
token: str,
|
|
37
|
+
*,
|
|
38
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
39
|
+
timeout: float = 120.0,
|
|
40
|
+
max_retries: int = 2,
|
|
41
|
+
retry_backoff: float = 0.25,
|
|
42
|
+
transport: httpx.BaseTransport | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
options = client_options(token, base_url, timeout, max_retries, retry_backoff)
|
|
45
|
+
self._http = httpx.Client(**options, transport=transport)
|
|
46
|
+
self._max_retries = max_retries
|
|
47
|
+
self._retry_backoff = retry_backoff
|
|
48
|
+
self.envs = Environments(self)
|
|
49
|
+
self.tasks = Tasks(self)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(cls, **overrides: Any) -> Self:
|
|
53
|
+
return cls(**environment_config(overrides))
|
|
54
|
+
|
|
55
|
+
def __enter__(self) -> Self:
|
|
56
|
+
return self
|
|
57
|
+
|
|
58
|
+
def __exit__(
|
|
59
|
+
self,
|
|
60
|
+
exc_type: type[BaseException] | None,
|
|
61
|
+
exc_value: BaseException | None,
|
|
62
|
+
traceback: TracebackType | None,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._http.close()
|
|
68
|
+
|
|
69
|
+
def _request(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
body: JsonObject | None = None,
|
|
75
|
+
params: dict[str, str] | None = None,
|
|
76
|
+
) -> JsonObject:
|
|
77
|
+
# The shared runtime does not deduplicate mutations. Never retry a POST.
|
|
78
|
+
retries = self._max_retries if method == "GET" else 0
|
|
79
|
+
for attempt in range(retries + 1):
|
|
80
|
+
response: httpx.Response | None = None
|
|
81
|
+
try:
|
|
82
|
+
response = self._http.request(method, path, json=body, params=params)
|
|
83
|
+
except httpx.TransportError as exc:
|
|
84
|
+
if attempt == retries:
|
|
85
|
+
raise TransportError(may_have_executed=method != "GET") from exc
|
|
86
|
+
else:
|
|
87
|
+
if response.status_code not in RETRYABLE_STATUS or attempt == retries:
|
|
88
|
+
return decode_response(response)
|
|
89
|
+
time.sleep(retry_delay(response, attempt, self._retry_backoff))
|
|
90
|
+
raise AssertionError("Unreachable retry state")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Environments:
|
|
94
|
+
def __init__(self, client: Client) -> None:
|
|
95
|
+
self._client = client
|
|
96
|
+
|
|
97
|
+
def get(self, instance_id: str) -> "EnvironmentResource":
|
|
98
|
+
data = self._client._request("GET", f"instances/{identifier(instance_id)}")
|
|
99
|
+
info = parse_model(InstanceDocumentation, data)
|
|
100
|
+
if info.instance_id != instance_id:
|
|
101
|
+
raise ProtocolError("Server returned documentation for a different environment")
|
|
102
|
+
return EnvironmentResource(self._client, info)
|
|
103
|
+
|
|
104
|
+
def list(self) -> list["EnvironmentResource"]:
|
|
105
|
+
data = parse_model(InstanceList, self._client._request("GET", "instances"))
|
|
106
|
+
return [EnvironmentResource(self._client, info) for info in data.instances]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Tasks:
|
|
110
|
+
def __init__(self, client: Client) -> None:
|
|
111
|
+
self._client = client
|
|
112
|
+
|
|
113
|
+
def get(self, task_id: str) -> "TaskResource":
|
|
114
|
+
data = self._client._request("GET", f"tasks/{identifier(task_id)}/documentation")
|
|
115
|
+
info = parse_model(TaskDocumentation, data)
|
|
116
|
+
if info.task_id != task_id:
|
|
117
|
+
raise ProtocolError("Server returned documentation for a different task")
|
|
118
|
+
return TaskResource(self._client, info, info.instance_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class EnvironmentResource(EnvironmentHandle):
|
|
122
|
+
def __init__(self, client: Client, info: InstanceDocumentation) -> None:
|
|
123
|
+
super().__init__(info)
|
|
124
|
+
self.tasks = EnvironmentTasks(client, info)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class EnvironmentTasks:
|
|
128
|
+
def __init__(self, client: Client, info: InstanceDocumentation) -> None:
|
|
129
|
+
self._client = client
|
|
130
|
+
self._info = info
|
|
131
|
+
|
|
132
|
+
def list(self) -> list["TaskResource"]:
|
|
133
|
+
"""Return handles from the environment snapshot; load task schemas on use."""
|
|
134
|
+
return [
|
|
135
|
+
TaskResource(self._client, task, self._info.instance_id) for task in self._info.tasks
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
def get(self, task_id: str) -> "TaskResource":
|
|
139
|
+
if not any(task.task_id == task_id for task in self._info.tasks):
|
|
140
|
+
raise ConfigurationError("The task is not in this environment's documentation")
|
|
141
|
+
task = self._client.tasks.get(task_id)
|
|
142
|
+
if task.instance_id != self._info.instance_id:
|
|
143
|
+
raise ProtocolError("Server returned documentation for a different environment")
|
|
144
|
+
return task
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TaskResource(TaskHandle):
|
|
148
|
+
def __init__(
|
|
149
|
+
self, client: Client, info: TaskSummary | TaskDocumentation, instance_id: str
|
|
150
|
+
) -> None:
|
|
151
|
+
super().__init__(info, instance_id)
|
|
152
|
+
self._client = client
|
|
153
|
+
self.actions = Actions(self)
|
|
154
|
+
|
|
155
|
+
def documentation(self) -> TaskDocumentation:
|
|
156
|
+
"""Fetch current learner documentation and refresh this resource's metadata."""
|
|
157
|
+
data = self._client._request("GET", f"tasks/{identifier(self.task_id)}/documentation")
|
|
158
|
+
info = parse_model(TaskDocumentation, data)
|
|
159
|
+
if info.task_id != self.task_id or info.instance_id != self.instance_id:
|
|
160
|
+
raise ProtocolError("Server returned documentation for a different task or environment")
|
|
161
|
+
self.info = info
|
|
162
|
+
return info
|
|
163
|
+
|
|
164
|
+
def state(self) -> RuntimeResponse:
|
|
165
|
+
return parse_model(
|
|
166
|
+
RuntimeResponse,
|
|
167
|
+
self._client._request("GET", "state", params={"task_id": identifier(self.task_id)}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def act(self, payload: JsonObject) -> RuntimeResponse:
|
|
171
|
+
"""Send the task's native payload, including chat schemas without an action name."""
|
|
172
|
+
documentation = self.documentation()
|
|
173
|
+
return self._act(validate_payload(documentation.action_payload_schema, payload))
|
|
174
|
+
|
|
175
|
+
def _act(self, payload: JsonObject) -> RuntimeResponse:
|
|
176
|
+
return parse_model(
|
|
177
|
+
RuntimeResponse,
|
|
178
|
+
self._client._request(
|
|
179
|
+
"POST",
|
|
180
|
+
"action",
|
|
181
|
+
body={"task_id": identifier(self.task_id), "action_payload": payload},
|
|
182
|
+
),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def grade(self, payload: JsonObject | None = None) -> RuntimeResponse:
|
|
186
|
+
validated = json_object(payload if payload is not None else {})
|
|
187
|
+
if not self.documentation().supports_standalone_grading:
|
|
188
|
+
raise ActionValidationError(
|
|
189
|
+
"Standalone grading is unavailable; inspect the task's documented actions"
|
|
190
|
+
)
|
|
191
|
+
return parse_model(
|
|
192
|
+
RuntimeResponse,
|
|
193
|
+
self._client._request(
|
|
194
|
+
"POST",
|
|
195
|
+
"grade",
|
|
196
|
+
body={
|
|
197
|
+
"task_id": identifier(self.task_id),
|
|
198
|
+
"payload": validated,
|
|
199
|
+
},
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def reset(self) -> RuntimeResponse:
|
|
204
|
+
"""Reset the user's shared environment through the task's existing reset behavior."""
|
|
205
|
+
return parse_model(
|
|
206
|
+
RuntimeResponse,
|
|
207
|
+
self._client._request("POST", "reset", body={"task_id": identifier(self.task_id)}),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Actions:
|
|
212
|
+
def __init__(self, task: TaskResource) -> None:
|
|
213
|
+
self._task = task
|
|
214
|
+
|
|
215
|
+
def list(self) -> list[ActionDescriptor]:
|
|
216
|
+
return self._task.documentation().actions
|
|
217
|
+
|
|
218
|
+
def call(self, name: str, arguments: JsonObject) -> RuntimeResponse:
|
|
219
|
+
"""Validate documented arguments and insert the native action discriminator."""
|
|
220
|
+
documentation = self._task.documentation()
|
|
221
|
+
return self._task._act(action_payload(documentation, name, arguments))
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""SDK failures omit authentication headers and locally supplied payloads."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SDKError(Exception):
|
|
7
|
+
"""Base class for SDK failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(SDKError, ValueError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ProtocolError(SDKError):
|
|
15
|
+
"""The server returned an invalid response or unsupported JSON schema."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActionValidationError(SDKError, ValueError):
|
|
19
|
+
"""Arguments do not satisfy the task's documented action schema."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, path: list[str | int] | None = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.path = path or []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class APIError(SDKError):
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
code: str,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
status_code: int,
|
|
33
|
+
details: dict[str, Any] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(f"{code}: {message}")
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
self.status_code = status_code
|
|
39
|
+
self.details = details or {}
|
|
40
|
+
usage = self.details.get("usage")
|
|
41
|
+
self.usage: dict[str, Any] | None = usage if isinstance(usage, dict) else None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthenticationError(APIError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PermissionDeniedError(APIError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NotFoundError(APIError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConflictError(APIError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LimitExceededError(APIError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TransportError(SDKError):
|
|
65
|
+
"""No definitive response was received; a mutation may already have executed."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, *, may_have_executed: bool) -> None:
|
|
68
|
+
message = "Unable to obtain a definitive server response."
|
|
69
|
+
if may_have_executed:
|
|
70
|
+
message += " The action may have executed; inspect task.state() before retrying."
|
|
71
|
+
super().__init__(message)
|
|
72
|
+
self.may_have_executed = may_have_executed
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def api_error(
|
|
76
|
+
code: str,
|
|
77
|
+
message: str,
|
|
78
|
+
*,
|
|
79
|
+
status_code: int,
|
|
80
|
+
details: dict[str, Any] | None = None,
|
|
81
|
+
) -> APIError:
|
|
82
|
+
cls: type[APIError] = {
|
|
83
|
+
401: AuthenticationError,
|
|
84
|
+
403: PermissionDeniedError,
|
|
85
|
+
404: NotFoundError,
|
|
86
|
+
409: ConflictError,
|
|
87
|
+
429: LimitExceededError,
|
|
88
|
+
}.get(status_code, APIError)
|
|
89
|
+
return cls(code, message, status_code=status_code, details=details)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Documentation and responses from the shared agent-env API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
JsonObject = dict[str, Any]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PublicModel(BaseModel):
|
|
11
|
+
"""Preserve additive server fields as data, never executable behavior."""
|
|
12
|
+
|
|
13
|
+
model_config = ConfigDict(extra="allow")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ActionDescriptor(PublicModel):
|
|
17
|
+
name: str
|
|
18
|
+
description: str
|
|
19
|
+
input_schema: JsonObject
|
|
20
|
+
examples: list[JsonObject] = Field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskSummary(PublicModel):
|
|
24
|
+
task_id: str
|
|
25
|
+
ctf_ref: str
|
|
26
|
+
title: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class InstanceDocumentation(PublicModel):
|
|
30
|
+
instance_id: str
|
|
31
|
+
agent_env_ref: str
|
|
32
|
+
title: str
|
|
33
|
+
description: str
|
|
34
|
+
tasks: list[TaskSummary]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InstanceList(PublicModel):
|
|
38
|
+
instances: list[InstanceDocumentation]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TaskDocumentation(TaskSummary):
|
|
42
|
+
instance_id: str
|
|
43
|
+
agent_env_ref: str
|
|
44
|
+
description: str
|
|
45
|
+
instructions: str
|
|
46
|
+
action_payload_schema: JsonObject
|
|
47
|
+
action_payload_examples: list[JsonObject] = Field(default_factory=list)
|
|
48
|
+
actions: list[ActionDescriptor] = Field(default_factory=list)
|
|
49
|
+
supports_grading: bool
|
|
50
|
+
supports_standalone_grading: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RuntimeResponse(PublicModel):
|
|
54
|
+
status: str
|
|
55
|
+
agent_env_ref: str | None = None
|
|
56
|
+
ctf_ref: str | None = None
|
|
57
|
+
state: JsonObject | None = None
|
|
58
|
+
response: JsonObject | None = None
|
|
59
|
+
completed: bool | None = None
|
|
60
|
+
grader_passed: bool | None = None
|
|
61
|
+
grader_result: JsonObject | None = None
|
|
62
|
+
missing_prerequisites: list[JsonObject] | None = None
|
|
63
|
+
usage: JsonObject | None = None
|
|
64
|
+
message: str | None = None
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: hundredflags-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python client for HundredFlags agent environment documentation and actions
|
|
5
|
+
Author: germankochnev
|
|
6
|
+
Author-email: germankochnev <kochgerm@gmail.com>
|
|
7
|
+
Requires-Dist: httpx>=0.28,<1
|
|
8
|
+
Requires-Dist: pydantic>=2.10,<3
|
|
9
|
+
Requires-Dist: jsonschema>=4.23,<5
|
|
10
|
+
Requires-Dist: referencing>=0.35,<1
|
|
11
|
+
Requires-Python: >=3.12
|
|
12
|
+
Project-URL: Homepage, https://github.com/ai-security-lab-itmo/hundredflags-sdk
|
|
13
|
+
Project-URL: Documentation, https://github.com/ai-security-lab-itmo/hundredflags-sdk#readme
|
|
14
|
+
Project-URL: Issues, https://github.com/ai-security-lab-itmo/hundredflags-sdk/issues
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# HundredFlags SDK
|
|
18
|
+
|
|
19
|
+
Python-клиент для работы с агентными средами полигона HundredFlags.
|
|
20
|
+
SDK получает документацию конкретной задачи и вызывает тот же `agent-env` API,
|
|
21
|
+
которым пользуется браузер: состояние, действия, проверку и сброс.
|
|
22
|
+
|
|
23
|
+
## Установка
|
|
24
|
+
|
|
25
|
+
Требуется Python 3.12 или новее.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
python -m pip install hundredflags-sdk
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Для воспроизводимой установки этой версии:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
python -m pip install hundredflags-sdk==0.4.0
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Версия 0.4.0 использует контракт платформы `2026-09-runtime-1` и работает с
|
|
38
|
+
существующими экземплярами `agent-env` и их задачами. Обновляйте SDK вместе с
|
|
39
|
+
платформой; отдельные лаборатории или прогоны создавать не нужно.
|
|
40
|
+
|
|
41
|
+
## Переход с ai-security-school-sdk
|
|
42
|
+
|
|
43
|
+
Начиная с 0.4.0 пакет называется `hundredflags-sdk`, а основной импорт —
|
|
44
|
+
`hundredflags_sdk`. API задач и контракт `2026-09-runtime-1` сохранены.
|
|
45
|
+
|
|
46
|
+
Для перехода из существующего окружения:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
python -m pip uninstall ai-security-school-sdk
|
|
50
|
+
python -m pip install --upgrade hundredflags-sdk
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Удалите старую зависимость из `pyproject.toml` или `requirements.txt`, заменив
|
|
54
|
+
её на `hundredflags-sdk`. Не устанавливайте оба дистрибутива одновременно:
|
|
55
|
+
они содержат общий совместимый модуль `ai_security_school_sdk`.
|
|
56
|
+
Старые импорты клиентов, моделей и исключений продолжают работать через этот
|
|
57
|
+
модуль; реализация у него общая с `hundredflags_sdk`.
|
|
58
|
+
|
|
59
|
+
`AI_SECURITY_SCHOOL_TOKEN` и `AI_SECURITY_SCHOOL_BASE_URL` остаются совместимыми
|
|
60
|
+
именами переменных окружения. `HUNDREDFLAGS_TOKEN` и `HUNDREDFLAGS_BASE_URL`
|
|
61
|
+
имеют приоритет, когда заданы; аргументы `from_env(...)` имеют приоритет над ними.
|
|
62
|
+
|
|
63
|
+
## Подключение и документация
|
|
64
|
+
|
|
65
|
+
На странице операции откройте инструкцию подключения. Создайте временный API-ключ
|
|
66
|
+
в личном кабинете платформы и передайте его через переменную окружения. Ключ
|
|
67
|
+
принадлежит вашему аккаунту и работает со всеми доступными вам задачами;
|
|
68
|
+
конкретную задачу выбирайте по её `task_id` в SDK.
|
|
69
|
+
`HUNDREDFLAGS_BASE_URL` можно задать для другого развёртывания; по умолчанию
|
|
70
|
+
используется `https://plgn.hundredflags.ru`.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
export HUNDREDFLAGS_TOKEN="YOUR_TOKEN"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from hundredflags_sdk import Client
|
|
78
|
+
|
|
79
|
+
with Client.from_env() as client:
|
|
80
|
+
for env in client.envs.list():
|
|
81
|
+
print(env.instance_id, env.title)
|
|
82
|
+
for task in env.tasks.list():
|
|
83
|
+
print(task.task_id, task.title)
|
|
84
|
+
|
|
85
|
+
task = client.tasks.get("YOUR_TASK_ID")
|
|
86
|
+
docs = task.documentation()
|
|
87
|
+
print(docs.instructions)
|
|
88
|
+
print(docs.action_payload_schema)
|
|
89
|
+
print(docs.action_payload_examples)
|
|
90
|
+
for action in docs.actions:
|
|
91
|
+
print(action.name, action.description, action.input_schema, action.examples)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`client.envs.get(instance_id)` возвращает одну среду. `env.tasks.list()` возвращает
|
|
95
|
+
задачи из полученного списка; `env.tasks.get(task_id)` загружает документацию
|
|
96
|
+
выбранной задачи. Среда соответствует `agent-env-instance`, задача —
|
|
97
|
+
`ctf-instance`. Документация описывает доступные студенту точки входа, а не
|
|
98
|
+
внутренние инструменты агента. Набор действий и схемы приходят с сервера, поэтому
|
|
99
|
+
новая задача не требует новой версии SDK.
|
|
100
|
+
|
|
101
|
+
## Выполнение действий
|
|
102
|
+
|
|
103
|
+
Для действия с именем используйте `task.actions.call(name, arguments)`.
|
|
104
|
+
Имя и аргументы выбираются из документации конкретной задачи:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
with Client.from_env() as client:
|
|
108
|
+
task = client.tasks.get("YOUR_TASK_ID")
|
|
109
|
+
print(task.actions.list())
|
|
110
|
+
|
|
111
|
+
# Используйте это имя только если оно есть в документации выбранной задачи.
|
|
112
|
+
result = task.actions.call("send_message", {"message": "Проверь новый документ"})
|
|
113
|
+
print(result.status, result.response, result.state)
|
|
114
|
+
print(task.state().model_dump())
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Метод вставляет поле `action` в тело запроса. В `arguments` передаются остальные
|
|
118
|
+
поля; `input_schema` и `examples` описанного действия не содержат `action`.
|
|
119
|
+
SDK проверяет аргументы и полное тело по JSON Schema перед отправкой. Сервер
|
|
120
|
+
применяет проверки существующего обработчика действия, а также контролирует
|
|
121
|
+
доступ, пререквизиты и бюджет пользователя.
|
|
122
|
+
|
|
123
|
+
`task.act(payload)` принимает полное нативное тело действия. Так поддерживаются
|
|
124
|
+
и существующие среды, у которых нет именованных действий, например чат:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
with Client.from_env() as client:
|
|
128
|
+
task = client.tasks.get("YOUR_CHAT_TASK_ID")
|
|
129
|
+
docs = task.documentation()
|
|
130
|
+
print(docs.action_payload_schema, docs.action_payload_examples)
|
|
131
|
+
result = task.act({"message": "Привет"})
|
|
132
|
+
print(result.model_dump())
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Пустой `docs.actions` не означает отсутствие возможностей: используйте полную
|
|
136
|
+
схему `action_payload_schema`. SDK не угадывает имена действий по коду runtime.
|
|
137
|
+
Документация не исполняется как Python-код, внешние ссылки JSON Schema не
|
|
138
|
+
загружаются. Внешние поверхности сценария, например MCP-сервис или реестр
|
|
139
|
+
зависимостей, используются через их собственные интерфейсы.
|
|
140
|
+
|
|
141
|
+
## Состояние, проверка и сброс
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
with Client.from_env() as client:
|
|
145
|
+
task = client.tasks.get("YOUR_TASK_ID")
|
|
146
|
+
current = task.state()
|
|
147
|
+
print(current.status, current.missing_prerequisites)
|
|
148
|
+
|
|
149
|
+
if task.documentation().supports_standalone_grading:
|
|
150
|
+
verdict = task.grade()
|
|
151
|
+
print(verdict.grader_passed, verdict.grader_result, verdict.completed)
|
|
152
|
+
|
|
153
|
+
# При необходимости передайте task.grade({...}) полезную нагрузку проверки.
|
|
154
|
+
# Явный сброс через существующее поведение среды:
|
|
155
|
+
# task.reset()
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`supports_grading` означает наличие оценивания вообще; `supports_standalone_grading`
|
|
159
|
+
разрешает отдельный вызов `grade()`. Некоторые задания оценивают ответ внутри
|
|
160
|
+
своих действий, например `submit_card` или `submit_finding`.
|
|
161
|
+
|
|
162
|
+
У одного пользователя задачи одной среды разделяют состояние с браузером и
|
|
163
|
+
другими скриптами. Получение нового handle или создание второго клиента не
|
|
164
|
+
создаёт отдельную попытку. Сброс затрагивает общее состояние среды; сохранение
|
|
165
|
+
зачётов и пререквизитов определяется её существующим поведением. Локальный
|
|
166
|
+
контекстный менеджер закрывает только HTTP-соединения.
|
|
167
|
+
|
|
168
|
+
Алгоритм атаки работает в вашем Python-процессе. Вызовы возвращают обычные ответы
|
|
169
|
+
runtime без фоновых заданий SDK, checkpoint, fork или воспроизведения сценария.
|
|
170
|
+
Вызовы одной среды выполняйте последовательно: параллельные кандидаты будут
|
|
171
|
+
менять одно состояние. Async-клиент удобен для неблокирующего ожидания и работы
|
|
172
|
+
с разными независимыми средами.
|
|
173
|
+
|
|
174
|
+
## Async
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import asyncio
|
|
178
|
+
from hundredflags_sdk import AsyncClient
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def main():
|
|
182
|
+
async with AsyncClient.from_env() as client:
|
|
183
|
+
task = await client.tasks.get("YOUR_TASK_ID")
|
|
184
|
+
docs = await task.documentation()
|
|
185
|
+
print(docs.instructions)
|
|
186
|
+
result = await task.act({"message": "Привет"}) # Если разрешено схемой.
|
|
187
|
+
print(result.response)
|
|
188
|
+
print((await task.state()).state)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
asyncio.run(main())
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Методы async-ресурсов, включая `env.tasks.list()`, вызываются через `await`.
|
|
195
|
+
Конструкторы, `from_env()`, поля `.info`, `.task_id`, `.instance_id` и `.title`
|
|
196
|
+
синхронные. `task.documentation()` обновляет снимок `.info`; `task.state()`
|
|
197
|
+
получает текущее серверное состояние. Дополнительные поля ответов сохраняются
|
|
198
|
+
в моделях и доступны через `model_dump()`.
|
|
199
|
+
|
|
200
|
+
Примеры: [документация и вызов](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/first_experiment.py),
|
|
201
|
+
[последовательный поиск кандидатов](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/async_search.py),
|
|
202
|
+
[связанные задачи одной среды](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/v0.4.0/examples/multistage.py).
|
|
203
|
+
|
|
204
|
+
## Ошибки и сетевые повторы
|
|
205
|
+
|
|
206
|
+
- `APIError` содержит `code`, `message`, `status_code`, `details` и `usage`.
|
|
207
|
+
Для HTTP 401/403/404/409/429 используются `AuthenticationError`,
|
|
208
|
+
`PermissionDeniedError`, `NotFoundError`, `ConflictError`, `LimitExceededError`.
|
|
209
|
+
- Успешный HTTP-ответ с `status="locked"` остаётся `RuntimeResponse`:
|
|
210
|
+
проверьте `status` и `missing_prerequisites` перед дальнейшими действиями.
|
|
211
|
+
- `ActionValidationError` означает локальное несоответствие схеме,
|
|
212
|
+
`ProtocolError` — некорректный ответ или неподдерживаемую ссылку в схеме.
|
|
213
|
+
- Автоматические повторы допускаются только для GET: при сетевых ошибках и
|
|
214
|
+
HTTP 429/502/503/504. Параметры клиента: `timeout=120`, `max_retries=2`,
|
|
215
|
+
`retry_backoff=0.25`; `max_retries=0` отключает повторы.
|
|
216
|
+
- Действия, проверка и сброс **никогда не повторяются автоматически**.
|
|
217
|
+
При сетевом сбое `TransportError.may_have_executed` показывает, что изменение
|
|
218
|
+
могло уже выполниться. Сначала изучите `task.state()` и только затем решайте,
|
|
219
|
+
нужен ли повтор. Таймаут или отмена async-корутины не доказывают, что сервер
|
|
220
|
+
остановил исполнение.
|
|
221
|
+
- Для удалённого сервера требуется HTTPS. HTTP доступен для локальной разработки;
|
|
222
|
+
перенаправления HTTP не выполняются, чтобы не передавать токен другому адресу.
|
|
223
|
+
|
|
224
|
+
## Разработка
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
uv sync --python 3.12
|
|
228
|
+
uv run pytest
|
|
229
|
+
uv run ruff check .
|
|
230
|
+
uv run mypy src
|
|
231
|
+
uv build
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Пакет не импортирует backend платформы. Sync/async тестируются через HTTPX
|
|
235
|
+
MockTransport против одного контракта `/api/agent-env`.
|
|
236
|
+
Публикация описана в [PUBLISHING.md](https://github.com/ai-security-lab-itmo/hundredflags-sdk/blob/main/PUBLISHING.md).
|
|
237
|
+
|
|
238
|
+
## Runtime contract
|
|
239
|
+
|
|
240
|
+
Version 0.3 targets the coordinated `2026-09-runtime-1` platform release. HTTP failures use `{ "error": { "code", "message", "details" }, "usage", "retry_after" }`. Task prerequisite and completion metadata refer to explicit task IDs; shared environment state does not imply shared task credit. Upgrade the platform, course clients, and SDK together.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ai_security_school_sdk/__init__.py,sha256=I4v9sUJ6uj6DH9vTe1CMuFZqmmoKeUq9dSKgCm3xwWQ,1494
|
|
2
|
+
ai_security_school_sdk/async_client.py,sha256=S0rPROmQ53bDX7EN7S7j9b9-jZvGJnugxJe-kFeDFvg,701
|
|
3
|
+
ai_security_school_sdk/client.py,sha256=xOUJfI-1xOoDbHFxPDrJKhTPGOOkYhW4chKbHpW9EGE,583
|
|
4
|
+
ai_security_school_sdk/errors.py,sha256=vmyiBLUAkJvgnt4WSa6HYrR9JYhMNBtP3mkhDu7goeA,1029
|
|
5
|
+
ai_security_school_sdk/models.py,sha256=EVocNE-PevYveWyY9eMlEMqxk8lz0os8WKjMU1vETuU,705
|
|
6
|
+
ai_security_school_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
hundredflags_sdk/__init__.py,sha256=LbDnXMXXubXQgIlnzkaJ6unSX1taEHE86gnpxFjBWcM,1073
|
|
8
|
+
hundredflags_sdk/_handles.py,sha256=jaN9RjQ6otSoLkDEJ6s2jCR2xF0gTb0hfePmWUV4SCU,843
|
|
9
|
+
hundredflags_sdk/_http.py,sha256=E5R9bgAo85zQli04cz_HOeQKANanr8ciWO7yRfMC_dU,5039
|
|
10
|
+
hundredflags_sdk/_schema.py,sha256=HvHidhn1wD5XTvT8oe-J1B2i5QWzaNNOm04Olrrhy9g,2855
|
|
11
|
+
hundredflags_sdk/async_client.py,sha256=3wkXTheSnyCSCeQrJp-m403jTOyZhxCk3YjKmJlqw2U,8459
|
|
12
|
+
hundredflags_sdk/client.py,sha256=yeY-05hrJ69Zqrzeyg8gIlUWzbyUox5paRYAa7EknFw,8017
|
|
13
|
+
hundredflags_sdk/errors.py,sha256=3mywC8Fkw0b7xfEXgNKw8iFN-MylXHj0KSXEl369hMA,2239
|
|
14
|
+
hundredflags_sdk/models.py,sha256=2uYomyHl_VnvWIsX5_6OLNVN1QBK7Myvpz9xTCRHJ2A,1613
|
|
15
|
+
hundredflags_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
hundredflags_sdk-0.4.0.dist-info/WHEEL,sha256=cmC5s21ojypbVslldL7IJq3hjZH-tINy4rziKePFsG0,81
|
|
17
|
+
hundredflags_sdk-0.4.0.dist-info/METADATA,sha256=P70NGbyLKiS81Xmje_AiRv7TLy3rGHboV-UCL9Vr798,13974
|
|
18
|
+
hundredflags_sdk-0.4.0.dist-info/RECORD,,
|