optimizely-opal.opal-tools-sdk 0.1.40.dev0__tar.gz → 0.1.41.dev0__tar.gz
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.
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/PKG-INFO +1 -1
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/__init__.py +82 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/config.py +61 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/context.py +40 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/decorators.py +51 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/models.py +24 -2
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/proteus.py +88 -77
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/response.py +381 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/service.py +730 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/optimizely_opal.opal_tools_sdk.egg-info/PKG-INFO +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/optimizely_opal.opal_tools_sdk.egg-info/SOURCES.txt +10 -2
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/pyproject.toml +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/setup.py +1 -1
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_async_tool.py +569 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_auth_middleware.py +245 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_dynamic_ui.py +157 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_hmac.py +183 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/tests/test_integration.py +116 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_response.py +400 -0
- optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_rollback.py +480 -0
- optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/__init__.py +0 -34
- optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/auth.py +0 -41
- optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/service.py +0 -398
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/README.md +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/_registry.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/logging.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/opal_tools_sdk/ui.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/optimizely_opal.opal_tools_sdk.egg-info/dependency_links.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/optimizely_opal.opal_tools_sdk.egg-info/requires.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/optimizely_opal.opal_tools_sdk.egg-info/top_level.txt +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/setup.cfg +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/tests/test_nested_schema.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/tests/test_proteus.py +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from .auth import EpiHmac, requires_auth
|
|
2
|
+
from .config import SdkConfig
|
|
3
|
+
from .context import ToolExecutionContext
|
|
4
|
+
from .decorators import interaction, resource, rollback, tool
|
|
5
|
+
from .logging import register_logger_factory
|
|
6
|
+
from .models import (
|
|
7
|
+
AuthData,
|
|
8
|
+
AuthRequirement,
|
|
9
|
+
Credentials,
|
|
10
|
+
Environment,
|
|
11
|
+
InteractionContext,
|
|
12
|
+
IslandConfig,
|
|
13
|
+
IslandResponse,
|
|
14
|
+
)
|
|
15
|
+
from .proteus import UI
|
|
16
|
+
from .response import (
|
|
17
|
+
DISCOVERY_MIME_TYPE,
|
|
18
|
+
RESPONSE_MIME_TYPE,
|
|
19
|
+
AffectedObject,
|
|
20
|
+
AsyncCallback,
|
|
21
|
+
CallbackDeliveryError,
|
|
22
|
+
ComposableToolResponse,
|
|
23
|
+
OperationResult,
|
|
24
|
+
ProblemDetail,
|
|
25
|
+
RollbackSpec,
|
|
26
|
+
ToolResponse,
|
|
27
|
+
async_timeout,
|
|
28
|
+
authentication_required,
|
|
29
|
+
permission_denied,
|
|
30
|
+
provider_error,
|
|
31
|
+
rate_limited,
|
|
32
|
+
resource_conflict,
|
|
33
|
+
resource_not_found,
|
|
34
|
+
validation_error,
|
|
35
|
+
)
|
|
36
|
+
from .service import ToolsService
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.14-dev"
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ToolsService",
|
|
41
|
+
"tool",
|
|
42
|
+
"resource",
|
|
43
|
+
"interaction",
|
|
44
|
+
"rollback",
|
|
45
|
+
"requires_auth",
|
|
46
|
+
"register_logger_factory",
|
|
47
|
+
# Auth (v2)
|
|
48
|
+
"SdkConfig",
|
|
49
|
+
"EpiHmac",
|
|
50
|
+
# Context
|
|
51
|
+
"ToolExecutionContext",
|
|
52
|
+
"AsyncCallback",
|
|
53
|
+
"CallbackDeliveryError",
|
|
54
|
+
# Models
|
|
55
|
+
"AuthData",
|
|
56
|
+
"AuthRequirement",
|
|
57
|
+
"Credentials",
|
|
58
|
+
"Environment",
|
|
59
|
+
"InteractionContext",
|
|
60
|
+
"IslandConfig",
|
|
61
|
+
"IslandResponse",
|
|
62
|
+
# Response envelope (v2)
|
|
63
|
+
"ComposableToolResponse",
|
|
64
|
+
"ToolResponse",
|
|
65
|
+
"OperationResult",
|
|
66
|
+
"RollbackSpec",
|
|
67
|
+
"AffectedObject",
|
|
68
|
+
"ProblemDetail",
|
|
69
|
+
"DISCOVERY_MIME_TYPE",
|
|
70
|
+
"RESPONSE_MIME_TYPE",
|
|
71
|
+
# Error helpers
|
|
72
|
+
"validation_error",
|
|
73
|
+
"authentication_required",
|
|
74
|
+
"resource_not_found",
|
|
75
|
+
"permission_denied",
|
|
76
|
+
"resource_conflict",
|
|
77
|
+
"rate_limited",
|
|
78
|
+
"provider_error",
|
|
79
|
+
"async_timeout",
|
|
80
|
+
# UI
|
|
81
|
+
"UI",
|
|
82
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""SDK configuration for authentication modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, SecretStr, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SdkConfig(BaseModel):
|
|
13
|
+
"""Configuration for Opal Tools SDK authentication.
|
|
14
|
+
|
|
15
|
+
Only needed when the tool provider requires request authentication.
|
|
16
|
+
All other v2 features are auto-detected from usage.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
auth_mode: Literal["static_token", "hmac", "turnstile"] | None = None
|
|
20
|
+
bearer_token: str | None = None
|
|
21
|
+
hmac_app_key: str | None = None
|
|
22
|
+
hmac_secret: SecretStr | None = None # base64-encoded, masked in logs/repr
|
|
23
|
+
|
|
24
|
+
@model_validator(mode="after")
|
|
25
|
+
def _require_credentials_for_auth_mode(self) -> SdkConfig:
|
|
26
|
+
"""Fail closed on misconfiguration so authentication can't silently no-op.
|
|
27
|
+
|
|
28
|
+
A configured ``auth_mode`` must carry the credentials it needs:
|
|
29
|
+
|
|
30
|
+
- ``static_token`` -> a non-empty ``bearer_token``
|
|
31
|
+
- ``hmac`` / ``turnstile`` -> a non-empty ``hmac_app_key`` and a
|
|
32
|
+
non-empty, valid base64 ``hmac_secret``
|
|
33
|
+
|
|
34
|
+
Without this, e.g. ``SdkConfig(auth_mode="hmac")`` would build an
|
|
35
|
+
EpiHmac with an empty secret and accept/produce signatures that no
|
|
36
|
+
real caller can match — an auth bypass that looks configured.
|
|
37
|
+
"""
|
|
38
|
+
if self.auth_mode is None:
|
|
39
|
+
return self
|
|
40
|
+
|
|
41
|
+
if self.auth_mode == "static_token":
|
|
42
|
+
if not (self.bearer_token and self.bearer_token.strip()):
|
|
43
|
+
raise ValueError("auth_mode='static_token' requires a non-empty bearer_token")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
# hmac / turnstile
|
|
47
|
+
if not (self.hmac_app_key and self.hmac_app_key.strip()):
|
|
48
|
+
raise ValueError(f"auth_mode='{self.auth_mode}' requires a non-empty hmac_app_key")
|
|
49
|
+
if not self.hmac_secret:
|
|
50
|
+
raise ValueError(f"auth_mode='{self.auth_mode}' requires a non-empty hmac_secret")
|
|
51
|
+
secret_value = self.hmac_secret.get_secret_value()
|
|
52
|
+
if not secret_value.strip():
|
|
53
|
+
raise ValueError(f"auth_mode='{self.auth_mode}' requires a non-empty hmac_secret")
|
|
54
|
+
try:
|
|
55
|
+
base64.b64decode(secret_value, validate=True)
|
|
56
|
+
except (binascii.Error, ValueError) as exc:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"auth_mode='{self.auth_mode}' hmac_secret must be valid base64"
|
|
59
|
+
) from exc
|
|
60
|
+
|
|
61
|
+
return self
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Execution context for Opal tool handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
from .models import Environment
|
|
8
|
+
from .response import AsyncCallback
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ToolExecutionContext(BaseModel):
|
|
12
|
+
"""Execution context passed to tool handlers as an optional third parameter.
|
|
13
|
+
|
|
14
|
+
Provides typed access to the execution environment and async callback.
|
|
15
|
+
Unknown environment properties are silently ignored for forward compatibility.
|
|
16
|
+
|
|
17
|
+
Usage::
|
|
18
|
+
|
|
19
|
+
@tool(name="report", mode="async", timeout=600)
|
|
20
|
+
async def report(params: ReportParams, auth_data: AuthData, context: ToolExecutionContext):
|
|
21
|
+
# context.environment["execution_mode"] → "headless" | "interactive"
|
|
22
|
+
# context.callback.url → callback URL (async tools only)
|
|
23
|
+
# context.callback is None → sync tools
|
|
24
|
+
# context.thread_id / workflow_execution_id / agent_execution_id
|
|
25
|
+
# → caller identity (from x-opal-* headers)
|
|
26
|
+
# context.auth → auth payload (same as the auth_data param)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(extra="ignore")
|
|
30
|
+
|
|
31
|
+
environment: Environment | None = None
|
|
32
|
+
callback: AsyncCallback | None = None
|
|
33
|
+
# Caller identity, populated from the x-opal-*-id request headers. Exactly one
|
|
34
|
+
# of these is typically set depending on the execution path (chat thread,
|
|
35
|
+
# workflow execution, or specialized-agent execution).
|
|
36
|
+
thread_id: str | None = None
|
|
37
|
+
workflow_execution_id: str | None = None
|
|
38
|
+
agent_execution_id: str | None = None
|
|
39
|
+
# Auth payload (mirrors the legacy `auth_data` handler parameter).
|
|
40
|
+
auth: dict | None = None
|
|
@@ -95,6 +95,9 @@ def tool(
|
|
|
95
95
|
description: str,
|
|
96
96
|
auth_requirements: list[dict[str, Any]] | None = None,
|
|
97
97
|
ui_resource: str | None = None,
|
|
98
|
+
mode: str = "sync",
|
|
99
|
+
timeout: int | None = None,
|
|
100
|
+
sensitive: str | bool | None = None,
|
|
98
101
|
wait: bool = False,
|
|
99
102
|
):
|
|
100
103
|
"""Decorator to register a function as an Opal tool.
|
|
@@ -122,6 +125,11 @@ def tool(
|
|
|
122
125
|
|
|
123
126
|
If ui_resource is specified, the frontend can fetch the resource to render a dynamic UI
|
|
124
127
|
for this tool without hardcoded integrations.
|
|
128
|
+
|
|
129
|
+
sensitive marks an action that needs user awareness/confirmation. Pass an
|
|
130
|
+
advisory string (e.g. "Verify the recipient") or True (a generic advisory
|
|
131
|
+
is substituted). It is published in discovery as a single nullable
|
|
132
|
+
string — present means sensitive, absent means not.
|
|
125
133
|
"""
|
|
126
134
|
|
|
127
135
|
def decorator(func: Callable):
|
|
@@ -297,6 +305,9 @@ def tool(
|
|
|
297
305
|
endpoint=endpoint,
|
|
298
306
|
auth_requirements=auth_req_list,
|
|
299
307
|
ui_resource=ui_resource,
|
|
308
|
+
mode=mode,
|
|
309
|
+
timeout=timeout,
|
|
310
|
+
sensitive=sensitive,
|
|
300
311
|
wait=wait,
|
|
301
312
|
)
|
|
302
313
|
|
|
@@ -305,6 +316,46 @@ def tool(
|
|
|
305
316
|
return decorator
|
|
306
317
|
|
|
307
318
|
|
|
319
|
+
def rollback(name: str):
|
|
320
|
+
"""Decorator to register a rollback (undo) handler.
|
|
321
|
+
|
|
322
|
+
The handler is auto-routed to ``POST /tools/rollbacks/{name}`` and
|
|
323
|
+
advertised in the ``rollbacks`` array of the discovery document. TMS calls
|
|
324
|
+
this endpoint to undo a previously executed operation.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
name: The rollback handler's id — independent of any tool name. A tool
|
|
328
|
+
opts into rollback by returning an ``OperationResult`` whose
|
|
329
|
+
``rollback.tool_name`` equals this value; several tools may share one
|
|
330
|
+
handler. Decorate the handler with ``@requires_auth`` to attach
|
|
331
|
+
OAuth requirements (published in discovery and forwarded on
|
|
332
|
+
invocation). Handlers must be idempotent: the invocation body
|
|
333
|
+
carries the originating ``operation_id`` (injected when the handler
|
|
334
|
+
declares an ``operation_id`` parameter) so retries can be
|
|
335
|
+
deduplicated.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def decorator(func: Callable):
|
|
339
|
+
endpoint = f"/tools/rollbacks/{name}"
|
|
340
|
+
|
|
341
|
+
logger.info(f"Registering rollback {name} with endpoint {endpoint}")
|
|
342
|
+
|
|
343
|
+
from . import _registry
|
|
344
|
+
|
|
345
|
+
if not _registry.services:
|
|
346
|
+
logger.warning(
|
|
347
|
+
"No services registered in registry! "
|
|
348
|
+
"Make sure to create ToolsService before decorating functions."
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
for service in _registry.services:
|
|
352
|
+
service.register_rollback(name=name, handler=func)
|
|
353
|
+
|
|
354
|
+
return func
|
|
355
|
+
|
|
356
|
+
return decorator
|
|
357
|
+
|
|
358
|
+
|
|
308
359
|
def interaction(
|
|
309
360
|
name: str,
|
|
310
361
|
description: str = "",
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from dataclasses import dataclass, field
|
|
2
2
|
from enum import Enum
|
|
3
|
-
from typing import Any, Literal
|
|
3
|
+
from typing import Any, Literal
|
|
4
4
|
|
|
5
5
|
from pydantic import BaseModel, Field
|
|
6
|
+
from typing_extensions import TypedDict # pydantic requires this on Python < 3.12
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class ParameterType(str, Enum):
|
|
@@ -82,13 +83,14 @@ class InteractionContext:
|
|
|
82
83
|
auth_data: AuthData | None = None
|
|
83
84
|
|
|
84
85
|
|
|
85
|
-
class Environment(TypedDict):
|
|
86
|
+
class Environment(TypedDict, total=False):
|
|
86
87
|
"""Execution environment for an Opal tool.
|
|
87
88
|
|
|
88
89
|
Interactive mode provides interaction islands, while headless does not.
|
|
89
90
|
"""
|
|
90
91
|
|
|
91
92
|
execution_mode: Literal["headless", "interactive"]
|
|
93
|
+
is_proteus_enabled: bool
|
|
92
94
|
|
|
93
95
|
|
|
94
96
|
@dataclass
|
|
@@ -102,8 +104,22 @@ class Function:
|
|
|
102
104
|
auth_requirements: list[AuthRequirement] | None = None
|
|
103
105
|
http_method: str = "POST"
|
|
104
106
|
ui_resource: str | None = None # UI resource URI for dynamic UI rendering
|
|
107
|
+
mode: str = "sync" # "sync" or "async"
|
|
108
|
+
timeout: int | None = None # seconds
|
|
109
|
+
# Ergonomic input (True | advisory str | None); normalized in __post_init__
|
|
110
|
+
# to a non-empty advisory string when sensitive, else None.
|
|
111
|
+
sensitive: str | bool | None = None
|
|
105
112
|
wait: bool = False # When True, pauses the agent until the caller signals completion
|
|
106
113
|
|
|
114
|
+
def __post_init__(self) -> None:
|
|
115
|
+
# Normalize `sensitive` to the wire shape: a single nullable advisory
|
|
116
|
+
# string. True -> a generic advisory; non-empty string -> itself;
|
|
117
|
+
# False / None / empty / whitespace -> None (not sensitive).
|
|
118
|
+
if self.sensitive is True:
|
|
119
|
+
self.sensitive = "This tool performs a sensitive action."
|
|
120
|
+
elif not (isinstance(self.sensitive, str) and self.sensitive.strip()):
|
|
121
|
+
self.sensitive = None
|
|
122
|
+
|
|
107
123
|
def to_dict(self) -> dict[str, Any]:
|
|
108
124
|
"""Convert to dictionary for the discovery endpoint."""
|
|
109
125
|
result: dict[str, Any] = {
|
|
@@ -120,6 +136,12 @@ class Function:
|
|
|
120
136
|
if self.ui_resource:
|
|
121
137
|
result["ui_resource"] = self.ui_resource
|
|
122
138
|
|
|
139
|
+
if self.mode != "sync":
|
|
140
|
+
result["mode"] = self.mode
|
|
141
|
+
if self.timeout:
|
|
142
|
+
result["timeout"] = self.timeout
|
|
143
|
+
if self.sensitive:
|
|
144
|
+
result["sensitive"] = self.sensitive
|
|
123
145
|
if self.wait:
|
|
124
146
|
result["wait"] = self.wait
|
|
125
147
|
|