optimizely-opal.opal-tools-sdk 0.1.40.dev0__tar.gz → 0.1.42.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.42.dev0}/PKG-INFO +1 -1
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/__init__.py +82 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/config.py +61 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/context.py +40 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/decorators.py +55 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/models.py +29 -1
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/proteus.py +88 -77
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/response.py +381 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/service.py +734 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.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.42.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.42.dev0}/pyproject.toml +1 -1
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/setup.py +1 -1
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_async_tool.py +569 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_auth_middleware.py +245 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_dynamic_ui.py +157 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_hmac.py +183 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/tests/test_integration.py +135 -0
- optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_response.py +400 -0
- optimizely_opal_opal_tools_sdk-0.1.42.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.42.dev0}/README.md +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/_registry.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/logging.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/opal_tools_sdk/ui.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.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.42.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.42.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.42.dev0}/setup.cfg +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/tests/test_nested_schema.py +0 -0
- {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.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,7 +95,11 @@ 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,
|
|
102
|
+
skippable_interaction: bool = False,
|
|
99
103
|
):
|
|
100
104
|
"""Decorator to register a function as an Opal tool.
|
|
101
105
|
|
|
@@ -111,6 +115,8 @@ def tool(
|
|
|
111
115
|
Example: "ui://my-app/create-form"
|
|
112
116
|
wait: When True, invoking this tool pauses the agent execution until the caller
|
|
113
117
|
signals completion.
|
|
118
|
+
skippable_interaction: When True, the tool's confirmation interaction (card) can be
|
|
119
|
+
skipped; a user who opts out has the tool run headless instead.
|
|
114
120
|
|
|
115
121
|
Returns:
|
|
116
122
|
Decorator function
|
|
@@ -122,6 +128,11 @@ def tool(
|
|
|
122
128
|
|
|
123
129
|
If ui_resource is specified, the frontend can fetch the resource to render a dynamic UI
|
|
124
130
|
for this tool without hardcoded integrations.
|
|
131
|
+
|
|
132
|
+
sensitive marks an action that needs user awareness/confirmation. Pass an
|
|
133
|
+
advisory string (e.g. "Verify the recipient") or True (a generic advisory
|
|
134
|
+
is substituted). It is published in discovery as a single nullable
|
|
135
|
+
string — present means sensitive, absent means not.
|
|
125
136
|
"""
|
|
126
137
|
|
|
127
138
|
def decorator(func: Callable):
|
|
@@ -297,9 +308,53 @@ def tool(
|
|
|
297
308
|
endpoint=endpoint,
|
|
298
309
|
auth_requirements=auth_req_list,
|
|
299
310
|
ui_resource=ui_resource,
|
|
311
|
+
mode=mode,
|
|
312
|
+
timeout=timeout,
|
|
313
|
+
sensitive=sensitive,
|
|
300
314
|
wait=wait,
|
|
315
|
+
skippable_interaction=skippable_interaction,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
return func
|
|
319
|
+
|
|
320
|
+
return decorator
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def rollback(name: str):
|
|
324
|
+
"""Decorator to register a rollback (undo) handler.
|
|
325
|
+
|
|
326
|
+
The handler is auto-routed to ``POST /tools/rollbacks/{name}`` and
|
|
327
|
+
advertised in the ``rollbacks`` array of the discovery document. TMS calls
|
|
328
|
+
this endpoint to undo a previously executed operation.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
name: The rollback handler's id — independent of any tool name. A tool
|
|
332
|
+
opts into rollback by returning an ``OperationResult`` whose
|
|
333
|
+
``rollback.tool_name`` equals this value; several tools may share one
|
|
334
|
+
handler. Decorate the handler with ``@requires_auth`` to attach
|
|
335
|
+
OAuth requirements (published in discovery and forwarded on
|
|
336
|
+
invocation). Handlers must be idempotent: the invocation body
|
|
337
|
+
carries the originating ``operation_id`` (injected when the handler
|
|
338
|
+
declares an ``operation_id`` parameter) so retries can be
|
|
339
|
+
deduplicated.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def decorator(func: Callable):
|
|
343
|
+
endpoint = f"/tools/rollbacks/{name}"
|
|
344
|
+
|
|
345
|
+
logger.info(f"Registering rollback {name} with endpoint {endpoint}")
|
|
346
|
+
|
|
347
|
+
from . import _registry
|
|
348
|
+
|
|
349
|
+
if not _registry.services:
|
|
350
|
+
logger.warning(
|
|
351
|
+
"No services registered in registry! "
|
|
352
|
+
"Make sure to create ToolsService before decorating functions."
|
|
301
353
|
)
|
|
302
354
|
|
|
355
|
+
for service in _registry.services:
|
|
356
|
+
service.register_rollback(name=name, handler=func)
|
|
357
|
+
|
|
303
358
|
return func
|
|
304
359
|
|
|
305
360
|
return decorator
|
|
@@ -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 NotRequired, TypedDict # pydantic requires this on Python < 3.12
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class ParameterType(str, Enum):
|
|
@@ -86,9 +87,13 @@ class Environment(TypedDict):
|
|
|
86
87
|
"""Execution environment for an Opal tool.
|
|
87
88
|
|
|
88
89
|
Interactive mode provides interaction islands, while headless does not.
|
|
90
|
+
`execution_mode` is always sent by TMS — required. `is_proteus_enabled`
|
|
91
|
+
was added later, so older TMS versions may omit it; mark it `NotRequired`
|
|
92
|
+
for cross-version forward compatibility.
|
|
89
93
|
"""
|
|
90
94
|
|
|
91
95
|
execution_mode: Literal["headless", "interactive"]
|
|
96
|
+
is_proteus_enabled: NotRequired[bool]
|
|
92
97
|
|
|
93
98
|
|
|
94
99
|
@dataclass
|
|
@@ -102,7 +107,22 @@ class Function:
|
|
|
102
107
|
auth_requirements: list[AuthRequirement] | None = None
|
|
103
108
|
http_method: str = "POST"
|
|
104
109
|
ui_resource: str | None = None # UI resource URI for dynamic UI rendering
|
|
110
|
+
mode: str = "sync" # "sync" or "async"
|
|
111
|
+
timeout: int | None = None # seconds
|
|
112
|
+
# Ergonomic input (True | advisory str | None); normalized in __post_init__
|
|
113
|
+
# to a non-empty advisory string when sensitive, else None.
|
|
114
|
+
sensitive: str | bool | None = None
|
|
105
115
|
wait: bool = False # When True, pauses the agent until the caller signals completion
|
|
116
|
+
skippable_interaction: bool = False # When True, the tool's confirmation card can be skipped
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
# Normalize `sensitive` to the wire shape: a single nullable advisory
|
|
120
|
+
# string. True -> a generic advisory; non-empty string -> itself;
|
|
121
|
+
# False / None / empty / whitespace -> None (not sensitive).
|
|
122
|
+
if self.sensitive is True:
|
|
123
|
+
self.sensitive = "This tool performs a sensitive action."
|
|
124
|
+
elif not (isinstance(self.sensitive, str) and self.sensitive.strip()):
|
|
125
|
+
self.sensitive = None
|
|
106
126
|
|
|
107
127
|
def to_dict(self) -> dict[str, Any]:
|
|
108
128
|
"""Convert to dictionary for the discovery endpoint."""
|
|
@@ -120,8 +140,16 @@ class Function:
|
|
|
120
140
|
if self.ui_resource:
|
|
121
141
|
result["ui_resource"] = self.ui_resource
|
|
122
142
|
|
|
143
|
+
if self.mode != "sync":
|
|
144
|
+
result["mode"] = self.mode
|
|
145
|
+
if self.timeout:
|
|
146
|
+
result["timeout"] = self.timeout
|
|
147
|
+
if self.sensitive:
|
|
148
|
+
result["sensitive"] = self.sensitive
|
|
123
149
|
if self.wait:
|
|
124
150
|
result["wait"] = self.wait
|
|
151
|
+
if self.skippable_interaction:
|
|
152
|
+
result["skippable_interaction"] = self.skippable_interaction
|
|
125
153
|
|
|
126
154
|
return result
|
|
127
155
|
|