pipelines-sdk 0.1.7__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.
- pipelines/__init__.py +69 -0
- pipelines/_logging.py +32 -0
- pipelines/_transport.py +175 -0
- pipelines/_version.py +3 -0
- pipelines/artifacts.py +250 -0
- pipelines/async_client.py +681 -0
- pipelines/cli/__init__.py +5 -0
- pipelines/cli/_mcp_shim_entry.py +24 -0
- pipelines/cli/_odyssey_group.py +32 -0
- pipelines/cli/main.py +1414 -0
- pipelines/client.py +1528 -0
- pipelines/config.py +110 -0
- pipelines/errors.py +89 -0
- pipelines/odyssey/__init__.py +187 -0
- pipelines/odyssey/_apierrors.py +99 -0
- pipelines/odyssey/_constants.py +31 -0
- pipelines/odyssey/_debug.py +203 -0
- pipelines/odyssey/adapters/__init__.py +19 -0
- pipelines/odyssey/adapters/_actor.py +116 -0
- pipelines/odyssey/adapters/anthropic.py +377 -0
- pipelines/odyssey/adapters/langchain.py +964 -0
- pipelines/odyssey/adapters/openai_agents.py +945 -0
- pipelines/odyssey/adapters/strands.py +328 -0
- pipelines/odyssey/auth.py +86 -0
- pipelines/odyssey/cli/__init__.py +25 -0
- pipelines/odyssey/cli/_templates.py +656 -0
- pipelines/odyssey/cli/agents.py +437 -0
- pipelines/odyssey/cli/dev.py +826 -0
- pipelines/odyssey/cli/doctor.py +534 -0
- pipelines/odyssey/cli/dump_agent.py +297 -0
- pipelines/odyssey/cli/init.py +170 -0
- pipelines/odyssey/cli/main.py +220 -0
- pipelines/odyssey/cli/mcp.py +204 -0
- pipelines/odyssey/cli/tunnels.py +445 -0
- pipelines/odyssey/context.py +133 -0
- pipelines/odyssey/envelope.py +458 -0
- pipelines/odyssey/handler.py +384 -0
- pipelines/odyssey/headers.py +25 -0
- pipelines/odyssey/labels.py +79 -0
- pipelines/odyssey/lifecycle.py +111 -0
- pipelines/odyssey/mcp.py +150 -0
- pipelines/odyssey/mcp_local.py +65 -0
- pipelines/odyssey/mcp_rewrite.py +135 -0
- pipelines/odyssey/mcp_shim.py +387 -0
- pipelines/odyssey/mcp_stdio.py +194 -0
- pipelines/odyssey/proxy.py +732 -0
- pipelines/odyssey/reachability.py +69 -0
- pipelines/odyssey/registration.py +1316 -0
- pipelines/odyssey/response.py +143 -0
- pipelines/odyssey/tool_endpoints.py +783 -0
- pipelines/odyssey/tools.py +105 -0
- pipelines/odyssey/topology.py +136 -0
- pipelines/odyssey/traces.py +632 -0
- pipelines/py.typed +0 -0
- pipelines/types.py +173 -0
- pipelines_sdk-0.1.7.dist-info/METADATA +391 -0
- pipelines_sdk-0.1.7.dist-info/RECORD +60 -0
- pipelines_sdk-0.1.7.dist-info/WHEEL +4 -0
- pipelines_sdk-0.1.7.dist-info/entry_points.txt +4 -0
- pipelines_sdk-0.1.7.dist-info/licenses/LICENSE +13 -0
pipelines/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from ._logging import enable_debug_logging, get_logger
|
|
2
|
+
from ._version import __version__
|
|
3
|
+
from .artifacts import (
|
|
4
|
+
ARTIFACT_BUNDLE_TEMPLATE_KINDS,
|
|
5
|
+
ARTIFACT_BUNDLE_TYPE,
|
|
6
|
+
ArtifactValidationError,
|
|
7
|
+
artifact_bundle_template,
|
|
8
|
+
load_artifact_bundle,
|
|
9
|
+
validate_artifact_bundle,
|
|
10
|
+
)
|
|
11
|
+
from .async_client import AsyncPipelinesClient
|
|
12
|
+
from .client import DEFAULT_BASE_URL, PipelinesClient
|
|
13
|
+
from .config import (
|
|
14
|
+
NAMED_ENVIRONMENTS,
|
|
15
|
+
clear_config,
|
|
16
|
+
config_path,
|
|
17
|
+
load_config,
|
|
18
|
+
resolve_base_url,
|
|
19
|
+
resolve_credentials,
|
|
20
|
+
save_config,
|
|
21
|
+
unset_config_key,
|
|
22
|
+
)
|
|
23
|
+
from .errors import (
|
|
24
|
+
AuthenticationError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
ForbiddenError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
PipelinesAPIError,
|
|
29
|
+
PipelinesConnectionError,
|
|
30
|
+
PipelinesError,
|
|
31
|
+
PipelinesTimeoutError,
|
|
32
|
+
RateLimitError,
|
|
33
|
+
ServerError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ARTIFACT_BUNDLE_TYPE",
|
|
39
|
+
"ARTIFACT_BUNDLE_TEMPLATE_KINDS",
|
|
40
|
+
"ArtifactValidationError",
|
|
41
|
+
"AsyncPipelinesClient",
|
|
42
|
+
"AuthenticationError",
|
|
43
|
+
"ConflictError",
|
|
44
|
+
"DEFAULT_BASE_URL",
|
|
45
|
+
"ForbiddenError",
|
|
46
|
+
"NAMED_ENVIRONMENTS",
|
|
47
|
+
"NotFoundError",
|
|
48
|
+
"PipelinesAPIError",
|
|
49
|
+
"PipelinesConnectionError",
|
|
50
|
+
"PipelinesClient",
|
|
51
|
+
"PipelinesError",
|
|
52
|
+
"PipelinesTimeoutError",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"ServerError",
|
|
55
|
+
"ValidationError",
|
|
56
|
+
"__version__",
|
|
57
|
+
"artifact_bundle_template",
|
|
58
|
+
"clear_config",
|
|
59
|
+
"config_path",
|
|
60
|
+
"enable_debug_logging",
|
|
61
|
+
"get_logger",
|
|
62
|
+
"load_artifact_bundle",
|
|
63
|
+
"load_config",
|
|
64
|
+
"resolve_base_url",
|
|
65
|
+
"resolve_credentials",
|
|
66
|
+
"save_config",
|
|
67
|
+
"unset_config_key",
|
|
68
|
+
"validate_artifact_bundle",
|
|
69
|
+
]
|
pipelines/_logging.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
_LOGGER_NAME = "pipelines"
|
|
7
|
+
_DEBUG_ENV_VAR = "PIPELINES_DEBUG"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_logger() -> logging.Logger:
|
|
11
|
+
"""Return the SDK's root logger.
|
|
12
|
+
|
|
13
|
+
Library callers can configure handlers/level via standard ``logging``
|
|
14
|
+
APIs. By default the logger has a NullHandler so importing the SDK
|
|
15
|
+
does not produce log output unless the caller opts in.
|
|
16
|
+
"""
|
|
17
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
18
|
+
if not logger.handlers:
|
|
19
|
+
logger.addHandler(logging.NullHandler())
|
|
20
|
+
if os.getenv(_DEBUG_ENV_VAR) and logger.level == logging.NOTSET:
|
|
21
|
+
logger.setLevel(logging.DEBUG)
|
|
22
|
+
return logger
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def enable_debug_logging() -> None:
|
|
26
|
+
"""Convenience helper for CLI/--verbose: stream DEBUG records to stderr."""
|
|
27
|
+
logger = logging.getLogger(_LOGGER_NAME)
|
|
28
|
+
logger.setLevel(logging.DEBUG)
|
|
29
|
+
if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
|
|
30
|
+
handler = logging.StreamHandler()
|
|
31
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
|
|
32
|
+
logger.addHandler(handler)
|
pipelines/_transport.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Shared HTTP transport helpers used by both the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import platform
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._logging import get_logger
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
from .config import DEFAULT_BASE_URL
|
|
15
|
+
from .errors import (
|
|
16
|
+
PipelinesAPIError,
|
|
17
|
+
PipelinesError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
error_for_status,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_LOGGER = get_logger()
|
|
23
|
+
|
|
24
|
+
RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
|
|
25
|
+
SAFE_RETRY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
26
|
+
TERMINAL_STATUSES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"completed",
|
|
29
|
+
"failed",
|
|
30
|
+
"cancelled",
|
|
31
|
+
"partially_cancelled",
|
|
32
|
+
"completed_with_partial_results",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_user_agent() -> str:
|
|
38
|
+
py_impl = platform.python_implementation()
|
|
39
|
+
py_version = "{}.{}.{}".format(*sys.version_info[:3])
|
|
40
|
+
return f"pipelines-sdk/{__version__} {py_impl}/{py_version} httpx/{httpx.__version__}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
USER_AGENT = build_user_agent()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_base_url(base_url: str) -> str:
|
|
47
|
+
return base_url.rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def url_for(base_url: str, path: str) -> str:
|
|
51
|
+
normalized = path if path.startswith("/") else f"/{path}"
|
|
52
|
+
return f"{base_url}{normalized}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_headers(
|
|
56
|
+
api_key: str | None,
|
|
57
|
+
*,
|
|
58
|
+
accept: str = "application/json",
|
|
59
|
+
idempotency_key: str | None = None,
|
|
60
|
+
extra: Mapping[str, str] | None = None,
|
|
61
|
+
) -> dict[str, str]:
|
|
62
|
+
headers: dict[str, str] = {
|
|
63
|
+
"Accept": accept,
|
|
64
|
+
"User-Agent": USER_AGENT,
|
|
65
|
+
}
|
|
66
|
+
if api_key:
|
|
67
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
68
|
+
if idempotency_key:
|
|
69
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
70
|
+
if extra:
|
|
71
|
+
headers.update(extra)
|
|
72
|
+
return headers
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def should_retry(
|
|
76
|
+
method: str,
|
|
77
|
+
response: httpx.Response,
|
|
78
|
+
attempt: int,
|
|
79
|
+
max_retries: int,
|
|
80
|
+
has_idempotency_key: bool,
|
|
81
|
+
) -> bool:
|
|
82
|
+
if attempt >= max_retries:
|
|
83
|
+
return False
|
|
84
|
+
if response.status_code not in RETRYABLE_STATUS_CODES:
|
|
85
|
+
return False
|
|
86
|
+
return method.upper() in SAFE_RETRY_METHODS or has_idempotency_key
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def retry_delay(response: httpx.Response, attempt: int) -> float:
|
|
90
|
+
retry_after = response.headers.get("Retry-After")
|
|
91
|
+
if retry_after:
|
|
92
|
+
try:
|
|
93
|
+
return max(0.0, float(retry_after))
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return float(min(2**attempt, 30))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parse_retry_after(response: httpx.Response) -> float | None:
|
|
100
|
+
retry_after = response.headers.get("Retry-After")
|
|
101
|
+
if retry_after is None:
|
|
102
|
+
return None
|
|
103
|
+
try:
|
|
104
|
+
return max(0.0, float(retry_after))
|
|
105
|
+
except ValueError:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def raise_api_error(response: httpx.Response) -> None:
|
|
110
|
+
"""Pick the most specific PipelinesAPIError subclass for ``response``."""
|
|
111
|
+
try:
|
|
112
|
+
body: Any = response.json()
|
|
113
|
+
except ValueError:
|
|
114
|
+
body = response.text
|
|
115
|
+
|
|
116
|
+
error_cls = error_for_status(response.status_code)
|
|
117
|
+
message = error_message(body)
|
|
118
|
+
|
|
119
|
+
_LOGGER.debug(
|
|
120
|
+
"pipelines api error: %s %s -> %s %s",
|
|
121
|
+
response.request.method if response.request else "?",
|
|
122
|
+
response.request.url if response.request else "?",
|
|
123
|
+
response.status_code,
|
|
124
|
+
message,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if error_cls is RateLimitError:
|
|
128
|
+
raise RateLimitError(
|
|
129
|
+
response.status_code,
|
|
130
|
+
message,
|
|
131
|
+
body=body,
|
|
132
|
+
retry_after=parse_retry_after(response),
|
|
133
|
+
)
|
|
134
|
+
raise error_cls(response.status_code, message, body=body)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def error_message(body: Any) -> str:
|
|
138
|
+
if not isinstance(body, dict):
|
|
139
|
+
return str(body)
|
|
140
|
+
|
|
141
|
+
detail = body.get("detail", body)
|
|
142
|
+
if isinstance(detail, str):
|
|
143
|
+
return detail
|
|
144
|
+
if isinstance(detail, list):
|
|
145
|
+
formatted_errors = []
|
|
146
|
+
for item in detail:
|
|
147
|
+
if not isinstance(item, dict):
|
|
148
|
+
formatted_errors.append(str(item))
|
|
149
|
+
continue
|
|
150
|
+
loc = item.get("loc")
|
|
151
|
+
location = ".".join(str(part) for part in loc) if isinstance(loc, list) else None
|
|
152
|
+
msg = item.get("msg", item)
|
|
153
|
+
formatted_errors.append(f"{location}: {msg}" if location else str(msg))
|
|
154
|
+
return "; ".join(formatted_errors)
|
|
155
|
+
return str(detail)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = [
|
|
159
|
+
"DEFAULT_BASE_URL",
|
|
160
|
+
"PipelinesAPIError",
|
|
161
|
+
"PipelinesError",
|
|
162
|
+
"RETRYABLE_STATUS_CODES",
|
|
163
|
+
"SAFE_RETRY_METHODS",
|
|
164
|
+
"TERMINAL_STATUSES",
|
|
165
|
+
"USER_AGENT",
|
|
166
|
+
"build_headers",
|
|
167
|
+
"build_user_agent",
|
|
168
|
+
"error_message",
|
|
169
|
+
"normalize_base_url",
|
|
170
|
+
"parse_retry_after",
|
|
171
|
+
"raise_api_error",
|
|
172
|
+
"retry_delay",
|
|
173
|
+
"should_retry",
|
|
174
|
+
"url_for",
|
|
175
|
+
]
|
pipelines/_version.py
ADDED
pipelines/artifacts.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .errors import PipelinesError
|
|
9
|
+
|
|
10
|
+
ARTIFACT_BUNDLE_TYPE = "pipelines.artifact.bundle.v1"
|
|
11
|
+
ARTIFACT_BUNDLE_TEMPLATE_KINDS = {
|
|
12
|
+
"bundle",
|
|
13
|
+
"prompts",
|
|
14
|
+
"criteria",
|
|
15
|
+
"evaluations",
|
|
16
|
+
"tool-endpoints",
|
|
17
|
+
"tool-bindings",
|
|
18
|
+
"tool-ground-truths",
|
|
19
|
+
}
|
|
20
|
+
ARTIFACT_BUNDLE_SECTIONS = (
|
|
21
|
+
"prompts",
|
|
22
|
+
"criteria",
|
|
23
|
+
"evaluations",
|
|
24
|
+
"experiments",
|
|
25
|
+
"tool_endpoints",
|
|
26
|
+
"tool_bindings",
|
|
27
|
+
"tool_ground_truths",
|
|
28
|
+
)
|
|
29
|
+
TEMPLATE_ORG_ID = 2
|
|
30
|
+
TEMPLATE_WORKFLOW_ID = 3086
|
|
31
|
+
TEMPLATE_CRITERION_IDS = {
|
|
32
|
+
"exact_match": 7,
|
|
33
|
+
"contains_keywords": 8,
|
|
34
|
+
"safety": 9,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ArtifactValidationError(PipelinesError):
|
|
39
|
+
"""Raised when an artifact manifest cannot be imported safely."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def artifact_bundle_template(kind: str = "prompts") -> dict[str, Any]:
|
|
43
|
+
if kind not in ARTIFACT_BUNDLE_TEMPLATE_KINDS:
|
|
44
|
+
raise ArtifactValidationError(
|
|
45
|
+
"Artifact bundle template kind must be one of: "
|
|
46
|
+
f"{sorted(ARTIFACT_BUNDLE_TEMPLATE_KINDS)}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Keep templates focused: one bundle should normally import one artifact family.
|
|
50
|
+
normalized_kind = "prompts" if kind == "bundle" else kind
|
|
51
|
+
bundle = {
|
|
52
|
+
"artifact_type": ARTIFACT_BUNDLE_TYPE,
|
|
53
|
+
"name": f"demo-{normalized_kind}-bundle",
|
|
54
|
+
"description": f"Portable {normalized_kind} configs that can be versioned in git.",
|
|
55
|
+
"prompts": [],
|
|
56
|
+
"criteria": [],
|
|
57
|
+
"evaluations": [],
|
|
58
|
+
"experiments": [],
|
|
59
|
+
"tool_endpoints": [],
|
|
60
|
+
"tool_bindings": [],
|
|
61
|
+
"tool_ground_truths": [],
|
|
62
|
+
}
|
|
63
|
+
if normalized_kind == "prompts":
|
|
64
|
+
bundle["prompts"] = [
|
|
65
|
+
{
|
|
66
|
+
"name": "Concise demo prompt",
|
|
67
|
+
"description": "Created from a Pipelines artifact bundle",
|
|
68
|
+
"prompt_text": "Write a concise answer for {{input}}.",
|
|
69
|
+
"prompt_placeholders": [{"name": "input"}],
|
|
70
|
+
"role": "user",
|
|
71
|
+
"scope_type": "org",
|
|
72
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "Detailed demo prompt",
|
|
76
|
+
"description": "Created from a Pipelines artifact bundle",
|
|
77
|
+
"prompt_text": "Write a detailed answer for {{input}} with examples.",
|
|
78
|
+
"prompt_placeholders": [{"name": "input"}],
|
|
79
|
+
"role": "user",
|
|
80
|
+
"scope_type": "org",
|
|
81
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
elif normalized_kind == "criteria":
|
|
85
|
+
bundle["criteria"] = [
|
|
86
|
+
{
|
|
87
|
+
"name": "Exact Match",
|
|
88
|
+
"display_label": "Exact Match",
|
|
89
|
+
"type": "programmatic",
|
|
90
|
+
"config": {"subtype": "exact_match"},
|
|
91
|
+
"output_schema": {"type": "boolean"},
|
|
92
|
+
"scope_type": "org",
|
|
93
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "Contains Keywords",
|
|
97
|
+
"display_label": "Contains Keywords",
|
|
98
|
+
"type": "programmatic",
|
|
99
|
+
"config": {
|
|
100
|
+
"subtype": "contains_keywords",
|
|
101
|
+
"keywords": ["required phrase", "important keyword"],
|
|
102
|
+
"mode": "any",
|
|
103
|
+
"case_sensitive": False,
|
|
104
|
+
},
|
|
105
|
+
"output_schema": {"type": "boolean"},
|
|
106
|
+
"scope_type": "org",
|
|
107
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
108
|
+
},
|
|
109
|
+
]
|
|
110
|
+
elif normalized_kind == "evaluations":
|
|
111
|
+
bundle["evaluations"] = [
|
|
112
|
+
{
|
|
113
|
+
"name": "Quality evaluation",
|
|
114
|
+
"description": "Created from a Pipelines artifact bundle",
|
|
115
|
+
"criteria": [
|
|
116
|
+
{"criterion_id": TEMPLATE_CRITERION_IDS["exact_match"]},
|
|
117
|
+
{"criterion_id": TEMPLATE_CRITERION_IDS["contains_keywords"]},
|
|
118
|
+
],
|
|
119
|
+
"scope_type": "org",
|
|
120
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "Safety evaluation",
|
|
124
|
+
"description": "Created from a Pipelines artifact bundle",
|
|
125
|
+
"criteria": [{"criterion_id": TEMPLATE_CRITERION_IDS["safety"]}],
|
|
126
|
+
"scope_type": "org",
|
|
127
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
128
|
+
},
|
|
129
|
+
]
|
|
130
|
+
elif normalized_kind == "tool-endpoints":
|
|
131
|
+
bundle["tool_endpoints"] = [
|
|
132
|
+
{
|
|
133
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
134
|
+
"name": "Docs MCP endpoint",
|
|
135
|
+
"description": "MCP server that exposes documentation search tools",
|
|
136
|
+
"endpoint_type": "mcp_server",
|
|
137
|
+
"url": "https://tools.example.com/mcp",
|
|
138
|
+
"auth_type": "none",
|
|
139
|
+
"auth_config": {},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"org_id": TEMPLATE_ORG_ID,
|
|
143
|
+
"name": "Support MCP endpoint",
|
|
144
|
+
"description": "MCP server that exposes support lookup tools",
|
|
145
|
+
"endpoint_type": "mcp_server",
|
|
146
|
+
"url": "https://support-tools.example.com/mcp",
|
|
147
|
+
"auth_type": "none",
|
|
148
|
+
"auth_config": {},
|
|
149
|
+
},
|
|
150
|
+
]
|
|
151
|
+
elif normalized_kind == "tool-bindings":
|
|
152
|
+
bundle["tool_bindings"] = [
|
|
153
|
+
{
|
|
154
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
155
|
+
"node_id": "replace-with-node-id",
|
|
156
|
+
"field_id": "replace-with-field-id",
|
|
157
|
+
"endpoint_id": "replace-with-tool-endpoint-id",
|
|
158
|
+
"selected_tools": ["search_docs"],
|
|
159
|
+
"max_tool_rounds": 3,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
163
|
+
"node_id": "replace-with-node-id",
|
|
164
|
+
"field_id": "replace-with-second-field-id",
|
|
165
|
+
"endpoint_id": "replace-with-tool-endpoint-id",
|
|
166
|
+
"selected_tools": ["fetch_url"],
|
|
167
|
+
"max_tool_rounds": 2,
|
|
168
|
+
},
|
|
169
|
+
]
|
|
170
|
+
elif normalized_kind == "tool-ground-truths":
|
|
171
|
+
bundle["tool_ground_truths"] = [
|
|
172
|
+
{
|
|
173
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
174
|
+
"node_id": "replace-with-node-id",
|
|
175
|
+
"field_id": "replace-with-field-id",
|
|
176
|
+
"expected_tools": [{"name": "search_docs"}],
|
|
177
|
+
"excluded_tools": [],
|
|
178
|
+
"ordering_matters": False,
|
|
179
|
+
"source": "manual",
|
|
180
|
+
"confidence": 1.0,
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"workflow_id": TEMPLATE_WORKFLOW_ID,
|
|
184
|
+
"node_id": "replace-with-node-id",
|
|
185
|
+
"field_id": "replace-with-second-field-id",
|
|
186
|
+
"expected_tools": [{"name": "fetch_url"}],
|
|
187
|
+
"excluded_tools": [{"name": "delete_record"}],
|
|
188
|
+
"ordering_matters": True,
|
|
189
|
+
"source": "manual",
|
|
190
|
+
"confidence": 0.9,
|
|
191
|
+
},
|
|
192
|
+
]
|
|
193
|
+
return bundle
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_artifact_bundle(path: str | Path) -> dict[str, Any]:
|
|
197
|
+
with Path(path).open(encoding="utf-8") as handle:
|
|
198
|
+
payload = json.load(handle)
|
|
199
|
+
if not isinstance(payload, dict):
|
|
200
|
+
raise ArtifactValidationError("Artifact bundle file must contain a JSON object")
|
|
201
|
+
return validate_artifact_bundle(payload)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_artifact_bundle(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
205
|
+
if payload.get("artifact_type") != ARTIFACT_BUNDLE_TYPE:
|
|
206
|
+
raise ArtifactValidationError(
|
|
207
|
+
f"Artifact bundle must include artifact_type='{ARTIFACT_BUNDLE_TYPE}'"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
bundle = dict(payload)
|
|
211
|
+
for section in ARTIFACT_BUNDLE_SECTIONS:
|
|
212
|
+
value = bundle.get(section, [])
|
|
213
|
+
if not isinstance(value, list):
|
|
214
|
+
raise ArtifactValidationError(f"Artifact bundle field '{section}' must be a list")
|
|
215
|
+
for index, item in enumerate(value):
|
|
216
|
+
if not isinstance(item, dict):
|
|
217
|
+
raise ArtifactValidationError(
|
|
218
|
+
f"Artifact bundle field '{section}[{index}]' must be a JSON object"
|
|
219
|
+
)
|
|
220
|
+
bundle[section] = value
|
|
221
|
+
|
|
222
|
+
for index, experiment in enumerate(bundle["experiments"]):
|
|
223
|
+
_require_experiment_field(experiment, index, "project_id")
|
|
224
|
+
_require_experiment_field(experiment, index, "workflow_id")
|
|
225
|
+
payload_value = experiment.get("payload")
|
|
226
|
+
if not isinstance(payload_value, dict):
|
|
227
|
+
raise ArtifactValidationError(
|
|
228
|
+
f"Artifact bundle experiment[{index}] must include a JSON object payload"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
for index, endpoint in enumerate(bundle["tool_endpoints"]):
|
|
232
|
+
_require_artifact_field(endpoint, "tool_endpoints", index, "org_id")
|
|
233
|
+
for index, binding in enumerate(bundle["tool_bindings"]):
|
|
234
|
+
_require_artifact_field(binding, "tool_bindings", index, "workflow_id")
|
|
235
|
+
for index, ground_truth in enumerate(bundle["tool_ground_truths"]):
|
|
236
|
+
_require_artifact_field(ground_truth, "tool_ground_truths", index, "workflow_id")
|
|
237
|
+
|
|
238
|
+
return bundle
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _require_experiment_field(experiment: Mapping[str, Any], index: int, field: str) -> None:
|
|
242
|
+
if experiment.get(field) is None:
|
|
243
|
+
raise ArtifactValidationError(f"Artifact bundle experiment[{index}] must include '{field}'")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _require_artifact_field(
|
|
247
|
+
artifact: Mapping[str, Any], section: str, index: int, field: str
|
|
248
|
+
) -> None:
|
|
249
|
+
if artifact.get(field) is None:
|
|
250
|
+
raise ArtifactValidationError(f"{section}[{index}] must include '{field}'")
|