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.
Files changed (33) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/PKG-INFO +1 -1
  2. optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/__init__.py +82 -0
  3. optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/config.py +61 -0
  4. optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/context.py +40 -0
  5. {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
  6. {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
  7. {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
  8. optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/response.py +381 -0
  9. optimizely_opal_opal_tools_sdk-0.1.42.dev0/opal_tools_sdk/service.py +734 -0
  10. {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
  11. {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
  12. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/pyproject.toml +1 -1
  13. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/setup.py +1 -1
  14. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_async_tool.py +569 -0
  15. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_auth_middleware.py +245 -0
  16. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_dynamic_ui.py +157 -0
  17. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_hmac.py +183 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/tests/test_integration.py +135 -0
  19. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_response.py +400 -0
  20. optimizely_opal_opal_tools_sdk-0.1.42.dev0/tests/test_rollback.py +480 -0
  21. optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/__init__.py +0 -34
  22. optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/auth.py +0 -41
  23. optimizely_opal_opal_tools_sdk-0.1.40.dev0/opal_tools_sdk/service.py +0 -398
  24. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/README.md +0 -0
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/setup.cfg +0 -0
  32. {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
  33. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.42.dev0}/tests/test_proteus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: optimizely-opal.opal-tools-sdk
3
- Version: 0.1.40.dev0
3
+ Version: 0.1.42.dev0
4
4
  Summary: SDK for creating Opal-compatible tools services
5
5
  Home-page: https://github.com/optimizely/opal-tools-sdk
6
6
  Author: Optimizely
@@ -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, TypedDict
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