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.
Files changed (33) hide show
  1. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/PKG-INFO +1 -1
  2. optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/__init__.py +82 -0
  3. optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/config.py +61 -0
  4. optimizely_opal_opal_tools_sdk-0.1.41.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.41.dev0}/opal_tools_sdk/decorators.py +51 -0
  6. {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
  7. {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
  8. optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/response.py +381 -0
  9. optimizely_opal_opal_tools_sdk-0.1.41.dev0/opal_tools_sdk/service.py +730 -0
  10. {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
  11. {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
  12. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/pyproject.toml +1 -1
  13. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/setup.py +1 -1
  14. optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_async_tool.py +569 -0
  15. optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_auth_middleware.py +245 -0
  16. optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_dynamic_ui.py +157 -0
  17. optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_hmac.py +183 -0
  18. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/tests/test_integration.py +116 -0
  19. optimizely_opal_opal_tools_sdk-0.1.41.dev0/tests/test_response.py +400 -0
  20. optimizely_opal_opal_tools_sdk-0.1.41.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.41.dev0}/README.md +0 -0
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.dev0}/setup.cfg +0 -0
  32. {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
  33. {optimizely_opal_opal_tools_sdk-0.1.40.dev0 → optimizely_opal_opal_tools_sdk-0.1.41.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.41.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,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, TypedDict
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