@macaw-ai/client 0.0.0-stage → 0.9.10

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 (142) hide show
  1. package/LICENSE.md +339 -0
  2. package/bin/install.js +278 -0
  3. package/package.json +37 -4
  4. package/payload/README.md +56 -0
  5. package/payload/secureAI/LICENSE +190 -0
  6. package/payload/secureAI/NOTICE +5 -0
  7. package/payload/secureAI/demos/policy-controls/1_uncontrolled.py +139 -0
  8. package/payload/secureAI/demos/policy-controls/2_with_macaw.py +139 -0
  9. package/payload/secureAI/demos/policy-controls/3_with_user_policies.py +162 -0
  10. package/payload/secureAI/demos/policy-controls/credentials.txt +12 -0
  11. package/payload/secureAI/demos/policy-controls/malicious_report.txt +21 -0
  12. package/payload/secureAI/demos/policy-controls/sales_report.txt +20 -0
  13. package/payload/secureAI/demos/tutorial-1-rbac/README.md +143 -0
  14. package/payload/secureAI/demos/tutorial-1-rbac/app.py +147 -0
  15. package/payload/secureAI/demos/tutorial-1-rbac/authprompts_demo.py +306 -0
  16. package/payload/secureAI/demos/tutorial-1-rbac/config/claims-config.yaml +152 -0
  17. package/payload/secureAI/demos/tutorial-1-rbac/demo_secureopenai_with_policies.py +150 -0
  18. package/payload/secureAI/demos/tutorial-1-rbac/policies/bu_Analytics.json +8 -0
  19. package/payload/secureAI/demos/tutorial-1-rbac/policies/company_FinTech_Corp.json +70 -0
  20. package/payload/secureAI/demos/tutorial-1-rbac/policies/llm-base.json +69 -0
  21. package/payload/secureAI/demos/tutorial-1-rbac/policies/team_Reporting.json +8 -0
  22. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_alice.json +32 -0
  23. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_bob.json +28 -0
  24. package/payload/secureAI/demos/tutorial-1-rbac/policies/user_carol.json +34 -0
  25. package/payload/secureAI/demos/tutorial-1-rbac/requirements.txt +6 -0
  26. package/payload/secureAI/demos/tutorial-1-rbac/setup/auth0_complete_setup.sh +392 -0
  27. package/payload/secureAI/demos/tutorial-1-rbac/setup/keycloak_complete_setup.sh +320 -0
  28. package/payload/secureAI/demos/tutorial-2-intent/Policies/alation-remote-proxy.json +155 -0
  29. package/payload/secureAI/demos/tutorial-2-intent/Policies/bu.json +22 -0
  30. package/payload/secureAI/demos/tutorial-2-intent/Policies/company.json +17 -0
  31. package/payload/secureAI/demos/tutorial-2-intent/Policies/user_alice.json +48 -0
  32. package/payload/secureAI/demos/tutorial-2-intent/Policies/user_bob.json +83 -0
  33. package/payload/secureAI/demos/tutorial-2-intent/README.md +213 -0
  34. package/payload/secureAI/demos/tutorial-2-intent/script.py +188 -0
  35. package/payload/secureAI/demos/tutorial-2-intent/setup/alation_verifier.py +177 -0
  36. package/payload/secureAI/demos/tutorial-2-intent/setup/get_alation_user_token.py +180 -0
  37. package/payload/secureAI/examples/README.md +244 -0
  38. package/payload/secureAI/examples/anthropic/anthropic_1a_dropin_simple.py +103 -0
  39. package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind.py +190 -0
  40. package/payload/secureAI/examples/anthropic/anthropic_1b_multiuser_bind_streaming.py +190 -0
  41. package/payload/secureAI/examples/anthropic/anthropic_1c_a2a_invoke.py +156 -0
  42. package/payload/secureAI/examples/attestations/1a_trade_alice.py +215 -0
  43. package/payload/secureAI/examples/attestations/1a_trade_bob.py +164 -0
  44. package/payload/secureAI/examples/attestations/1b_grant_admin.py +170 -0
  45. package/payload/secureAI/examples/attestations/1b_grant_researcher.py +231 -0
  46. package/payload/secureAI/examples/attestations/README.md +128 -0
  47. package/payload/secureAI/examples/langchain/langchain_1a_dropin_simple.py +199 -0
  48. package/payload/secureAI/examples/langchain/langchain_1b_multiuser.py +262 -0
  49. package/payload/secureAI/examples/langchain/langchain_1c_orchestration.py +332 -0
  50. package/payload/secureAI/examples/langchain/langchain_1d_llm_openai.py +130 -0
  51. package/payload/secureAI/examples/langchain/langchain_1e_llm_anthropic.py +130 -0
  52. package/payload/secureAI/examples/langchain/langchain_1f_memory.py +214 -0
  53. package/payload/secureAI/examples/litellm/litellm_1a_dropin_simple.py +131 -0
  54. package/payload/secureAI/examples/mcp/1a_simple_invocation.py +115 -0
  55. package/payload/secureAI/examples/mcp/1b_discovery_and_resources.py +142 -0
  56. package/payload/secureAI/examples/mcp/1c_logging_client.py +100 -0
  57. package/payload/secureAI/examples/mcp/1d_progress_client.py +100 -0
  58. package/payload/secureAI/examples/mcp/1e_sampling_client.py +150 -0
  59. package/payload/secureAI/examples/mcp/1e_sampling_server.py +90 -0
  60. package/payload/secureAI/examples/mcp/1f_elicitation_client.py +127 -0
  61. package/payload/secureAI/examples/mcp/1f_elicitation_server.py +73 -0
  62. package/payload/secureAI/examples/mcp/1g_roots_client.py +122 -0
  63. package/payload/secureAI/examples/mcp/1g_roots_server.py +85 -0
  64. package/payload/secureAI/examples/mcp/2a_proxy_snowflake.py +212 -0
  65. package/payload/secureAI/examples/mcp/2b_proxy_databricks.py +299 -0
  66. package/payload/secureAI/examples/mcp/2c_proxy_stdio_fetch.py +91 -0
  67. package/payload/secureAI/examples/mcp/2d_proxy_stdio_salesforce.py +119 -0
  68. package/payload/secureAI/examples/mcp/2e_proxy_multiuser.py +136 -0
  69. package/payload/secureAI/examples/mcp/2f_proxy_stdio_databricks.py +144 -0
  70. package/payload/secureAI/examples/mcp/3a_native_mcp_client.py +104 -0
  71. package/payload/secureAI/examples/mcp/3b_native_mcp_proxy.py +114 -0
  72. package/payload/secureAI/examples/mcp/3c_native_mcp_sampling.py +180 -0
  73. package/payload/secureAI/examples/mcp/3d_native_mcp_2026.py +135 -0
  74. package/payload/secureAI/examples/mcp/securemcp_calculator.py +248 -0
  75. package/payload/secureAI/examples/mcp/securemcp_server.py +75 -0
  76. package/payload/secureAI/examples/openai/openai_1a_dropin_simple.py +102 -0
  77. package/payload/secureAI/examples/openai/openai_1b_multiuser_bind.py +193 -0
  78. package/payload/secureAI/examples/openai/openai_1b_multiuser_bind_streaming.py +199 -0
  79. package/payload/secureAI/examples/openai/openai_1c_a2a_invoke.py +156 -0
  80. package/payload/secureAI/examples/openai/openai_1d_invocation_patterns.py +337 -0
  81. package/payload/secureAI/examples/phishing-resistance/1a_login_alice.py +180 -0
  82. package/payload/secureAI/examples/phishing-resistance/1b_trade_alice.py +241 -0
  83. package/payload/secureAI/examples/phishing-resistance/1b_trade_bob.py +164 -0
  84. package/payload/secureAI/examples/phishing-resistance/README.md +124 -0
  85. package/payload/secureAI/examples/phishing-resistance/get_token.py +114 -0
  86. package/payload/secureAI/examples/pydantic_ai/README.md +88 -0
  87. package/payload/secureAI/examples/pydantic_ai/policies/app_pydantic-agent.json +10 -0
  88. package/payload/secureAI/examples/pydantic_ai/pydanticai_1a_dropin_simple.py +90 -0
  89. package/payload/secureAI/examples/pydantic_ai/pydanticai_1b_model_routing.py +85 -0
  90. package/payload/secureAI/examples/pydantic_ai/pydanticai_1c_native_tools.py +83 -0
  91. package/payload/secureAI/examples/pydantic_ai/pydanticai_1d_multiuser_bind.py +104 -0
  92. package/payload/secureAI/examples/pydantic_ai/pydanticai_1e_mcp_compose.py +92 -0
  93. package/payload/secureAI/macaw_adapters/__init__.py +55 -0
  94. package/payload/secureAI/macaw_adapters/anthropic/REFERENCE.md +374 -0
  95. package/payload/secureAI/macaw_adapters/anthropic/__init__.py +22 -0
  96. package/payload/secureAI/macaw_adapters/anthropic/secure_anthropic.py +792 -0
  97. package/payload/secureAI/macaw_adapters/anthropic_ext_tools/__init__.py +25 -0
  98. package/payload/secureAI/macaw_adapters/anthropic_ext_tools/secure_anthropic.py +840 -0
  99. package/payload/secureAI/macaw_adapters/langchain/REFERENCE.md +234 -0
  100. package/payload/secureAI/macaw_adapters/langchain/__init__.py +100 -0
  101. package/payload/secureAI/macaw_adapters/langchain/_utils.py +83 -0
  102. package/payload/secureAI/macaw_adapters/langchain/agents.py +296 -0
  103. package/payload/secureAI/macaw_adapters/langchain/anthropic.py +456 -0
  104. package/payload/secureAI/macaw_adapters/langchain/callbacks.py +306 -0
  105. package/payload/secureAI/macaw_adapters/langchain/memory.py +352 -0
  106. package/payload/secureAI/macaw_adapters/langchain/openai.py +410 -0
  107. package/payload/secureAI/macaw_adapters/langchain/tools.py +174 -0
  108. package/payload/secureAI/macaw_adapters/litellm/README.md +216 -0
  109. package/payload/secureAI/macaw_adapters/litellm/__init__.py +143 -0
  110. package/payload/secureAI/macaw_adapters/litellm/secure_litellm.py +686 -0
  111. package/payload/secureAI/macaw_adapters/mcp/REFERENCE.md +460 -0
  112. package/payload/secureAI/macaw_adapters/mcp/__init__.py +65 -0
  113. package/payload/secureAI/macaw_adapters/mcp/_endpoint.py +193 -0
  114. package/payload/secureAI/macaw_adapters/mcp/_endpoint2.py +225 -0
  115. package/payload/secureAI/macaw_adapters/mcp/_serve.py +25 -0
  116. package/payload/secureAI/macaw_adapters/mcp/client.py +721 -0
  117. package/payload/secureAI/macaw_adapters/mcp/decorators.py +228 -0
  118. package/payload/secureAI/macaw_adapters/mcp/fastmcp_compat.py +353 -0
  119. package/payload/secureAI/macaw_adapters/mcp/mcp.py +785 -0
  120. package/payload/secureAI/macaw_adapters/mcp/proxy.py +626 -0
  121. package/payload/secureAI/macaw_adapters/mcp/server.py +317 -0
  122. package/payload/secureAI/macaw_adapters/mcp/subscriptions.py +450 -0
  123. package/payload/secureAI/macaw_adapters/openai/REFERENCE.md +368 -0
  124. package/payload/secureAI/macaw_adapters/openai/__init__.py +22 -0
  125. package/payload/secureAI/macaw_adapters/openai/secure_openai.py +833 -0
  126. package/payload/secureAI/macaw_adapters/openai_ext_tools/REFERENCE.md +368 -0
  127. package/payload/secureAI/macaw_adapters/openai_ext_tools/__init__.py +25 -0
  128. package/payload/secureAI/macaw_adapters/openai_ext_tools/secure_openai.py +876 -0
  129. package/payload/secureAI/macaw_adapters/pydantic_ai/__init__.py +36 -0
  130. package/payload/secureAI/macaw_adapters/pydantic_ai/_core.py +257 -0
  131. package/payload/secureAI/macaw_adapters/pydantic_ai/agent.py +218 -0
  132. package/payload/secureAI/macaw_adapters/pydantic_ai/model.py +99 -0
  133. package/payload/secureAI/macaw_adapters/pydantic_ai/toolset.py +86 -0
  134. package/payload/secureAI/test_harness_quick.py +296 -0
  135. package/wheels/macaw_adapters-0.9.10.1-py3-none-any.whl +0 -0
  136. package/wheels/macaw_client-0.9.10.1-cp312-cp312-macosx_11_0_arm64.whl +0 -0
  137. package/wheels/macaw_client-0.9.10.1-cp312-cp312-manylinux_2_17_x86_64.whl +0 -0
  138. package/wheels/macaw_client-0.9.10.1-cp314-cp314-macosx_11_0_arm64.whl +0 -0
  139. package/wheels/macaw_client-0.9.10.1-cp314-cp314-manylinux_2_17_x86_64.whl +0 -0
  140. package/wheels/macaw_client-0.9.10.1-cp314-cp314-win_amd64.whl +0 -0
  141. package/wheels/macaw_client-0.9.10.1-cp39-cp39-macosx_11_0_arm64.whl +0 -0
  142. package/README.md +0 -3
@@ -0,0 +1,876 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SecureOpenAI - MACAW-protected OpenAI wrapper.
4
+
5
+ Drop-in replacement for OpenAI client with MACAW security.
6
+ Uses MAPL-compliant tool names: tool:<app_name>/generate, tool:<app_name>/complete, etc.
7
+
8
+ Supports three usage paths:
9
+ 1. Direct on service: client.chat.completions.create() - simplest, app-level identity
10
+ 2. bind_to_user: service.bind_to_user(user_client) - per-user identity for SaaS
11
+ 3. invoke_tool: user.invoke_tool("tool:xxx/generate", ...) - explicit A2A control
12
+
13
+ Architecture (service mode):
14
+ - macaw_client: Primary client owning LLM operations (tool:{app_name}/generate, etc.)
15
+ - _tools_client: Internal client owning user-registered tools (tool:{app_name}-tools/*, not externally reachable)
16
+ - server_id: Points to macaw_client (the primary interface)
17
+ - Tool isolation: Only macaw_client can invoke tools on _tools_client
18
+
19
+ Install with: pip install -e /path/to/secureAI
20
+ """
21
+
22
+ import os
23
+ import json
24
+ import logging
25
+ import inspect
26
+ from typing import Dict, Any, Optional, Callable, List
27
+
28
+ from openai import OpenAI
29
+ from macaw_client import MACAWClient
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # MAPL-compliant resource naming: tool:<service>/<operation>
34
+ # Will be formatted with actual app_name at initialization
35
+ TOOL_OPERATIONS = {
36
+ "generate": "generate",
37
+ "complete": "complete",
38
+ "embed": "embed"
39
+ }
40
+
41
+
42
+ class SecureOpenAI:
43
+ """
44
+ Drop-in replacement for OpenAI client with MACAW security.
45
+
46
+ This version uses a two-client architecture for tool isolation:
47
+ - macaw_client: Primary, owns LLM operations (tool:{app_name}/generate, etc.)
48
+ - _tools_client: Internal, owns user-registered tools (not externally reachable)
49
+
50
+ server_id points to macaw_client, making it the primary external interface.
51
+ Tools are isolated: only macaw_client can invoke tools on _tools_client.
52
+
53
+ Supports two modes:
54
+ 1. Service mode (default): Creates both clients, handles OpenAI calls
55
+ 2. User mode (jwt_token provided): Creates user agent with identity for direct calls
56
+
57
+ For multi-user scenarios, use service mode + bind_to_user() to create per-user wrappers.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ api_key: str = None,
63
+ app_name: str = None,
64
+ intent_policy: Optional[Dict[str, Any]] = None,
65
+ # User mode parameters
66
+ jwt_token: str = None,
67
+ user_name: str = None
68
+ ):
69
+ """
70
+ Initialize SecureOpenAI wrapper.
71
+
72
+ Args:
73
+ api_key: OpenAI API key (or from env)
74
+ app_name: Application name for registration
75
+ intent_policy: Application-defined MACAW intent policy
76
+ jwt_token: If provided, creates user agent with this identity (user mode)
77
+ user_name: Optional user name for user mode
78
+ """
79
+ # Get API key
80
+ self.api_key = api_key or os.environ.get('OPENAI_API_KEY')
81
+ if not self.api_key:
82
+ raise ValueError("OpenAI API key required")
83
+
84
+ # Real OpenAI client
85
+ self.openai_client = OpenAI(api_key=self.api_key)
86
+
87
+ # Application identity
88
+ self.app_name = app_name or "secure-openai-app"
89
+
90
+ # Determine mode based on jwt_token
91
+ self._mode = "user" if jwt_token else "service"
92
+ self._jwt_token = jwt_token
93
+ self._user_name = user_name
94
+
95
+ # User-registered tools (OpenAI can call these)
96
+ self.user_tools = {}
97
+
98
+ # Auto-discovered tools cache
99
+ self._discovered_tools = {}
100
+ self._tools_registered = False
101
+
102
+ # Application-provided intent policy (no defaults!)
103
+ self.intent_policy = intent_policy or {}
104
+
105
+ if self._mode == "service":
106
+ # SERVICE MODE: Two-client architecture for tool isolation
107
+ # - macaw_client: Primary, owns LLM operations (externally reachable)
108
+ # - _tools_client: Internal, owns user tools (not externally reachable)
109
+
110
+ # Tools client name (for trace clarity)
111
+ self._tools_name = f"{self.app_name}-tools"
112
+
113
+ # LLM tools on the primary client
114
+ llm_tools = {
115
+ f"tool:{self.app_name}/generate": {
116
+ "handler": self._handle_generate,
117
+ "prompts": ["messages"]
118
+ },
119
+ f"tool:{self.app_name}/complete": {
120
+ "handler": self._handle_complete,
121
+ "prompts": ["prompt"]
122
+ },
123
+ f"tool:{self.app_name}/embed": {
124
+ "handler": self._handle_embed,
125
+ "prompts": ["input"]
126
+ }
127
+ }
128
+
129
+ # Primary client - owns LLM operations, externally reachable via server_id
130
+ self.macaw_client = MACAWClient(
131
+ app_name=self.app_name,
132
+ app_version="1.0.0",
133
+ intent_policy=self.intent_policy,
134
+ tools=llm_tools
135
+ )
136
+
137
+ # Internal tools client - owns user-registered tools, NOT externally reachable
138
+ self._tools_client = MACAWClient(
139
+ app_name=self._tools_name,
140
+ app_version="1.0.0",
141
+ intent_policy=self.intent_policy,
142
+ tools={} # User tools added via register_tool
143
+ )
144
+ else:
145
+ # USER MODE: Create user agent with identity
146
+ self._tools_name = None
147
+ self._tools_client = None
148
+
149
+ self.macaw_client = MACAWClient(
150
+ user_name=user_name,
151
+ iam_token=jwt_token,
152
+ agent_type="user",
153
+ app_name=self.app_name,
154
+ app_version="1.0.0",
155
+ intent_policy=self.intent_policy or {
156
+ "purpose": f"OpenAI access for {user_name or 'user'}"
157
+ }
158
+ )
159
+
160
+ # Register with LocalAgent and get server ID(s)
161
+ if self._mode == "service":
162
+ # Register both clients
163
+ if self.macaw_client.register() and self._tools_client.register():
164
+ self.server_id = self.macaw_client.agent_id # Primary interface!
165
+ self._tools_server_id = self._tools_client.agent_id # Internal, not exposed
166
+ logger.info(f"✅ SecureOpenAI registered (two-client mode):")
167
+ logger.info(f" Primary (LLM): {self.server_id}")
168
+ logger.info(f" Internal (tools): {self._tools_server_id}")
169
+ else:
170
+ raise RuntimeError("Failed to register with MACAW LocalAgent")
171
+ else:
172
+ # User mode - single client
173
+ if self.macaw_client.register():
174
+ self.server_id = self.macaw_client.agent_id
175
+ logger.info(f"✅ SecureOpenAI registered as: {self.server_id} (mode: {self._mode})")
176
+ else:
177
+ raise RuntimeError("Failed to register with MACAW LocalAgent")
178
+
179
+ # Mimic OpenAI API structure
180
+ # MACAW-protected namespaces (route through PEP)
181
+ self.chat = self._ChatNamespace(self)
182
+ self.completions = self._CompletionsNamespace(self)
183
+ self.embeddings = self._EmbeddingsNamespace(self)
184
+
185
+ # =========================================================================
186
+ # Pass-through properties for non-MACAW-protected APIs
187
+ # These delegate directly to the underlying OpenAI client
188
+ # =========================================================================
189
+
190
+ @property
191
+ def models(self):
192
+ """
193
+ Pass-through to OpenAI models API.
194
+
195
+ Not MACAW-protected - provides direct access to model listing/retrieval.
196
+ """
197
+ return self.openai_client.models
198
+
199
+ @property
200
+ def images(self):
201
+ """
202
+ Pass-through to OpenAI images API (DALL-E).
203
+
204
+ Not MACAW-protected - image generation uses service's API key directly.
205
+ """
206
+ return self.openai_client.images
207
+
208
+ @property
209
+ def audio(self):
210
+ """
211
+ Pass-through to OpenAI audio API (Whisper/TTS).
212
+
213
+ Not MACAW-protected - audio transcription/speech uses service's API key directly.
214
+ """
215
+ return self.openai_client.audio
216
+
217
+ @property
218
+ def files(self):
219
+ """
220
+ Pass-through to OpenAI files API.
221
+
222
+ Not MACAW-protected - file operations use service's API key directly.
223
+ """
224
+ return self.openai_client.files
225
+
226
+ @property
227
+ def fine_tuning(self):
228
+ """
229
+ Pass-through to OpenAI fine-tuning API.
230
+
231
+ Not MACAW-protected - fine-tuning uses service's API key directly.
232
+ """
233
+ return self.openai_client.fine_tuning
234
+
235
+ @property
236
+ def moderations(self):
237
+ """
238
+ Pass-through to OpenAI moderations API.
239
+
240
+ Not MACAW-protected - content moderation uses service's API key directly.
241
+ """
242
+ return self.openai_client.moderations
243
+
244
+ @property
245
+ def batches(self):
246
+ """
247
+ Pass-through to OpenAI batches API.
248
+
249
+ Not MACAW-protected - batch operations use service's API key directly.
250
+ """
251
+ return self.openai_client.batches
252
+
253
+ @property
254
+ def beta(self):
255
+ """
256
+ Pass-through to OpenAI beta API (assistants, threads, vector stores).
257
+
258
+ Not MACAW-protected - beta APIs use service's API key directly.
259
+ """
260
+ return self.openai_client.beta
261
+
262
+ def bind_to_user(self, user_client: 'MACAWClient') -> 'BoundSecureOpenAI':
263
+ """
264
+ Bind this SecureOpenAI service to a user's MACAW client.
265
+
266
+ Creates a lightweight wrapper that routes calls through the user's
267
+ client to this service, enabling per-user identity and policy enforcement.
268
+
269
+ Only valid in service mode.
270
+
271
+ Security validations:
272
+ - Service must be in service mode
273
+ - User client must be a valid MACAWClient instance
274
+ - User client must be registered with LocalAgent
275
+ - User client should be a "user" type agent
276
+
277
+ Args:
278
+ user_client: A registered MACAWClient with user identity
279
+
280
+ Returns:
281
+ BoundSecureOpenAI wrapper for this user
282
+
283
+ Raises:
284
+ ValueError: If service not in service mode or client validation fails
285
+ """
286
+ if self._mode != "service":
287
+ raise ValueError("bind_to_user() only valid for service-mode SecureOpenAI")
288
+
289
+ # Validate user_client is a MACAWClient instance
290
+ if not hasattr(user_client, 'agent_id') or not hasattr(user_client, 'invoke_tool'):
291
+ raise ValueError("bind_to_user() requires a valid MACAWClient instance")
292
+
293
+ # Validate user_client is registered
294
+ if not getattr(user_client, 'registered', False):
295
+ raise ValueError("bind_to_user() requires a registered MACAWClient. Call register() first.")
296
+
297
+ # Validate agent_type is "user" (warning only, not blocking)
298
+ agent_type = getattr(user_client, 'agent_type', None)
299
+ if agent_type and agent_type != "user":
300
+ logger.warning(f"bind_to_user() called with agent_type='{agent_type}' (expected 'user'). "
301
+ f"User identity and policy enforcement may not work as expected.")
302
+
303
+ return BoundSecureOpenAI(self, user_client)
304
+
305
+ def register_tool(self, name: str, handler: Callable) -> 'SecureOpenAI':
306
+ """
307
+ Register a tool that OpenAI can call.
308
+
309
+ Tools are registered on _tools_client (internal, not externally reachable).
310
+ This provides tool isolation - only the LLM can invoke these tools.
311
+
312
+ Args:
313
+ name: Tool name (must match OpenAI function name)
314
+ handler: Function to handle tool execution
315
+
316
+ Returns:
317
+ Self for chaining
318
+ """
319
+ # Store with MAPL-compliant name using tools client's app_name
320
+ mapl_name = f"tool:{self._tools_name}/{name}"
321
+
322
+ # Wrap handler to accept params dict and unpack as kwargs
323
+ def wrapped_handler(params):
324
+ return handler(**params)
325
+
326
+ self.user_tools[name] = wrapped_handler # Store by simple name for lookup
327
+ self.user_tools[mapl_name] = wrapped_handler # Also store by MAPL name
328
+
329
+ # Register with _tools_client (internal) using MAPL name
330
+ self._tools_client.register_tool(mapl_name, wrapped_handler)
331
+
332
+ logger.info(f"Registered tool: {name} -> {mapl_name}")
333
+ return self
334
+
335
+ def _auto_discover_tools(self, tools_metadata):
336
+ """
337
+ Auto-discover tool implementations from caller's context.
338
+
339
+ Args:
340
+ tools_metadata: List of tool definitions from OpenAI call
341
+ """
342
+ if self._tools_registered:
343
+ return # Already discovered
344
+
345
+ # Get caller's frame (need to go back through the call stack)
346
+ frame = inspect.currentframe()
347
+ caller_frame = frame.f_back.f_back.f_back
348
+ if not caller_frame:
349
+ logger.warning("Could not access caller frame for auto-discovery")
350
+ return
351
+
352
+ caller_globals = caller_frame.f_globals
353
+ caller_locals = caller_frame.f_locals
354
+
355
+ # Combine locals and globals for search
356
+ search_scope = {**caller_globals, **caller_locals}
357
+
358
+ # Discover implementations for each tool
359
+ for tool_def in tools_metadata:
360
+ if isinstance(tool_def, dict) and tool_def.get("type") == "function":
361
+ func_def = tool_def.get("function", {})
362
+ func_name = func_def.get("name")
363
+
364
+ # Use MAPL-compliant name with tools client: tool:<app_name>-tools/<func_name>
365
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
366
+ if func_name and func_name not in self.user_tools:
367
+ # Look for function in caller's scope
368
+ if func_name in search_scope:
369
+ func = search_scope[func_name]
370
+ if callable(func):
371
+ logger.info(f"Auto-discovered tool: {func_name} -> {mapl_name}")
372
+ self._discovered_tools[func_name] = func
373
+ self.user_tools[func_name] = func
374
+ self.user_tools[mapl_name] = func
375
+ else:
376
+ logger.warning(f"Tool '{func_name}' not found in caller's scope")
377
+
378
+ # Sync all discovered tools with _tools_client (internal)
379
+ if self._discovered_tools:
380
+ # Register each discovered tool with _tools_client
381
+ for func_name, func in self._discovered_tools.items():
382
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
383
+ # Create wrapper handler that accepts dict parameter
384
+ def create_handler(tool_func):
385
+ def handler(params):
386
+ return tool_func(**params)
387
+ return handler
388
+ self._tools_client.register_tool(mapl_name, create_handler(func))
389
+ self._tools_registered = True
390
+ logger.info(f"Auto-registered {len(self._discovered_tools)} tools with _tools_client")
391
+
392
+ def _handle_generate(self, params: Dict[str, Any]):
393
+ """
394
+ Handle 'generate' tool (chat completions).
395
+
396
+ This runs on macaw_client. Tool callbacks route to _tools_client (internal).
397
+ Returns a serializable dict for MACAW protocol, or an iterator for streaming.
398
+ """
399
+ try:
400
+ # Check if streaming is requested
401
+ is_streaming = params.get('stream', False)
402
+
403
+ if is_streaming:
404
+ # Streaming mode: return iterator
405
+ return self._stream_generate(params)
406
+
407
+ # Non-streaming mode: existing behavior
408
+ # Call real OpenAI (params are clean - no MACAW internals)
409
+ response = self.openai_client.chat.completions.create(**params)
410
+
411
+ # Check if OpenAI wants to call tools
412
+ if response.choices[0].message.tool_calls:
413
+ tool_calls = response.choices[0].message.tool_calls
414
+ logger.info(f"OpenAI requested {len(tool_calls)} tool calls")
415
+
416
+ # Process each tool call
417
+ tool_results = []
418
+ for tool_call in tool_calls:
419
+ func_name = tool_call.function.name
420
+ func_args = json.loads(tool_call.function.arguments)
421
+ # Convert to MAPL-compliant name using tools client: tool:<app_name>-tools/<func_name>
422
+ mapl_name = f"tool:{self._tools_name}/{func_name}"
423
+
424
+ logger.info(f"Processing tool call: {func_name} -> {mapl_name}")
425
+
426
+ # Check if we have this tool (by simple name or MAPL name)
427
+ if func_name not in self.user_tools and mapl_name not in self.user_tools:
428
+ result = {
429
+ 'error': f"Tool not found: {mapl_name}",
430
+ 'available_tools': list(self.user_tools.keys())
431
+ }
432
+ else:
433
+ # Invoke tool through MACAW (goes through PEP!)
434
+ # Route from macaw_client to _tools_client (internal)
435
+ # This provides tool isolation: only LLM can call tools
436
+ try:
437
+ result = self.macaw_client.invoke_tool(
438
+ tool_name=mapl_name,
439
+ parameters=func_args,
440
+ target_agent=self._tools_server_id # Internal tools client
441
+ )
442
+ logger.info(f"Tool {func_name} executed successfully via _tools_client")
443
+ except Exception as e:
444
+ error_msg = str(e)
445
+ # Check if MACAW blocked it
446
+ if 'denied' in error_msg.lower() or 'policy' in error_msg.lower():
447
+ logger.warning(f"🛡️ MACAW blocked tool {func_name}: {error_msg}")
448
+ result = {
449
+ 'error': 'Access denied by security policy',
450
+ 'tool': func_name,
451
+ 'reason': 'Policy violation'
452
+ }
453
+ else:
454
+ result = {'error': str(e)}
455
+
456
+ tool_results.append({
457
+ 'tool_call_id': tool_call.id,
458
+ 'result': result
459
+ })
460
+
461
+ # Add tool results to conversation
462
+ messages = list(params.get('messages', []))
463
+
464
+ # Add assistant message with tool calls
465
+ messages.append({
466
+ "role": "assistant",
467
+ "content": None,
468
+ "tool_calls": [{
469
+ "id": tc.id,
470
+ "type": "function",
471
+ "function": {
472
+ "name": tc.function.name,
473
+ "arguments": tc.function.arguments
474
+ }
475
+ } for tc in tool_calls]
476
+ })
477
+
478
+ # Add tool results
479
+ for result in tool_results:
480
+ messages.append({
481
+ "role": "tool",
482
+ "tool_call_id": result['tool_call_id'],
483
+ "content": json.dumps(result['result'])
484
+ })
485
+
486
+ # Continue conversation with tool results
487
+ final_params = params.copy()
488
+ final_params['messages'] = messages
489
+
490
+ # Remove tools and tool_choice to prevent infinite recursion
491
+ if 'tools' in final_params:
492
+ del final_params['tools']
493
+ if 'tool_choice' in final_params:
494
+ del final_params['tool_choice']
495
+
496
+ final_response = self.openai_client.chat.completions.create(**final_params)
497
+
498
+ # Convert to serializable dict for MACAW protocol
499
+ return final_response.model_dump()
500
+
501
+ # No tool calls, convert response to dict
502
+ return response.model_dump()
503
+
504
+ except Exception as e:
505
+ logger.error(f"Error in generate handler: {e}")
506
+ return {'error': str(e)}
507
+
508
+ def _stream_generate(self, params: Dict[str, Any]):
509
+ """
510
+ Handle streaming chat completions.
511
+
512
+ Returns an iterator that yields chunks from the OpenAI streaming API.
513
+ Each chunk is converted to a serializable dict.
514
+ """
515
+ try:
516
+ # Call OpenAI with streaming
517
+ stream = self.openai_client.chat.completions.create(**params)
518
+
519
+ # Yield each chunk as a serializable dict
520
+ for chunk in stream:
521
+ yield chunk.model_dump()
522
+
523
+ except Exception as e:
524
+ logger.error(f"Error in streaming generate: {e}")
525
+ # Yield error as final chunk
526
+ yield {'error': str(e)}
527
+
528
+ def _handle_complete(self, params: Dict[str, Any]) -> Dict[str, Any]:
529
+ """
530
+ Handle 'complete' tool (text completions).
531
+
532
+ Returns a serializable dict for MACAW protocol.
533
+ """
534
+ try:
535
+ response = self.openai_client.completions.create(**params)
536
+ return response.model_dump()
537
+ except Exception as e:
538
+ logger.error(f"Error in complete handler: {e}")
539
+ return {'error': str(e)}
540
+
541
+ def _handle_embed(self, params: Dict[str, Any]) -> Dict[str, Any]:
542
+ """
543
+ Handle 'embed' tool (embeddings).
544
+
545
+ Returns a serializable dict for MACAW protocol.
546
+ """
547
+ try:
548
+ response = self.openai_client.embeddings.create(**params)
549
+ return response.model_dump()
550
+ except Exception as e:
551
+ logger.error(f"Error in embed handler: {e}")
552
+ return {'error': str(e)}
553
+
554
+ # Namespace classes to mimic OpenAI API structure
555
+ class _ChatNamespace:
556
+ def __init__(self, parent):
557
+ self.parent = parent
558
+ self.completions = self._Completions(parent)
559
+
560
+ class _Completions:
561
+ def __init__(self, parent):
562
+ self.parent = parent
563
+
564
+ def create(self, **kwargs):
565
+ """
566
+ Create chat completion - routes through MACAW.
567
+
568
+ This mimics OpenAI's API but goes through PEP!
569
+ Routes to macaw_client (the primary interface).
570
+ Supports streaming when stream=True is passed.
571
+ """
572
+ # Auto-discover tools if provided
573
+ tools = kwargs.get('tools', kwargs.get('functions'))
574
+ if tools:
575
+ self.parent._auto_discover_tools(tools)
576
+
577
+ # Route to primary client (macaw_client)
578
+ tool_name = f"tool:{self.parent.app_name}/generate"
579
+ is_streaming = kwargs.get('stream', False)
580
+
581
+ # Route through macaw_client (the primary LLM service)
582
+ result = self.parent.macaw_client.invoke_tool(
583
+ tool_name=tool_name,
584
+ parameters=kwargs,
585
+ target_agent=self.parent.server_id, # = macaw_client.agent_id
586
+ stream=is_streaming
587
+ )
588
+
589
+ if is_streaming:
590
+ # Streaming mode: result is a StreamingResultIterator, wrap in OpenAI types
591
+ return self._wrap_streaming_response(result)
592
+
593
+ # Non-streaming mode: handle errors and reconstruct type
594
+ if isinstance(result, dict) and 'error' in result:
595
+ raise Exception(f"MACAW error: {result['error']}")
596
+
597
+ # Reconstruct ChatCompletion from dict
598
+ from openai.types.chat import ChatCompletion
599
+ return ChatCompletion(**result)
600
+
601
+ def _wrap_streaming_response(self, iterator):
602
+ """Wrap streaming chunks in OpenAI ChatCompletionChunk objects."""
603
+ from openai.types.chat import ChatCompletionChunk
604
+
605
+ for chunk in iterator:
606
+ if isinstance(chunk, dict):
607
+ if 'error' in chunk:
608
+ raise Exception(f"MACAW streaming error: {chunk['error']}")
609
+ # Convert dict to ChatCompletionChunk
610
+ try:
611
+ yield ChatCompletionChunk(**chunk)
612
+ except Exception as e:
613
+ logger.warning(f"Could not convert chunk to ChatCompletionChunk: {e}")
614
+ yield chunk # Return raw dict if conversion fails
615
+ else:
616
+ yield chunk
617
+
618
+ class _CompletionsNamespace:
619
+ def __init__(self, parent):
620
+ self.parent = parent
621
+
622
+ def create(self, **kwargs):
623
+ """Create text completion - routes through MACAW."""
624
+ tool_name = f"tool:{self.parent.app_name}/complete"
625
+
626
+ # Route through macaw_client
627
+ result = self.parent.macaw_client.invoke_tool(
628
+ tool_name=tool_name,
629
+ parameters=kwargs,
630
+ target_agent=self.parent.server_id # = macaw_client.agent_id
631
+ )
632
+
633
+ # Handle errors
634
+ if isinstance(result, dict) and 'error' in result:
635
+ raise Exception(f"MACAW error: {result['error']}")
636
+
637
+ # Reconstruct Completion from dict
638
+ from openai.types import Completion
639
+ return Completion(**result)
640
+
641
+ class _EmbeddingsNamespace:
642
+ def __init__(self, parent):
643
+ self.parent = parent
644
+
645
+ def create(self, **kwargs):
646
+ """Create embeddings - routes through MACAW."""
647
+ tool_name = f"tool:{self.parent.app_name}/embed"
648
+
649
+ # Route through macaw_client
650
+ result = self.parent.macaw_client.invoke_tool(
651
+ tool_name=tool_name,
652
+ parameters=kwargs,
653
+ target_agent=self.parent.server_id # = macaw_client.agent_id
654
+ )
655
+
656
+ # Handle errors
657
+ if isinstance(result, dict) and 'error' in result:
658
+ raise Exception(f"MACAW error: {result['error']}")
659
+
660
+ # Reconstruct CreateEmbeddingResponse from dict
661
+ from openai.types import CreateEmbeddingResponse
662
+ return CreateEmbeddingResponse(**result)
663
+
664
+
665
+ class BoundSecureOpenAI:
666
+ """
667
+ Per-user wrapper for SecureOpenAI service.
668
+
669
+ Created via SecureOpenAI.bind_to_user(user_client).
670
+ Routes all calls through the user's MACAWClient to the service's LLM client,
671
+ enabling per-user identity and policy enforcement.
672
+
673
+ Authenticated prompts are auto-created by invoke_tool based on
674
+ the service's prompts declaration in the registry.
675
+
676
+ Call unbind() to invalidate this binding when done.
677
+ """
678
+
679
+ def __init__(self, service: SecureOpenAI, user_client: 'MACAWClient'):
680
+ """
681
+ Initialize bound wrapper.
682
+
683
+ Args:
684
+ service: The shared SecureOpenAI service (must be in service mode)
685
+ user_client: User's registered MACAWClient with identity
686
+ """
687
+ self._service = service
688
+ self._user_client = user_client
689
+ self._bound = True
690
+
691
+ # Create OpenAI-compatible API namespaces
692
+ self.chat = self._ChatNamespace(self)
693
+ self.completions = self._CompletionsNamespace(self)
694
+ self.embeddings = self._EmbeddingsNamespace(self)
695
+
696
+ @property
697
+ def service(self) -> SecureOpenAI:
698
+ """Get the bound service (raises if unbound)."""
699
+ if not self._bound:
700
+ raise RuntimeError("BoundSecureOpenAI has been unbound. Create a new binding with bind_to_user().")
701
+ return self._service
702
+
703
+ @property
704
+ def user_client(self) -> 'MACAWClient':
705
+ """Get the bound user client (raises if unbound)."""
706
+ if not self._bound:
707
+ raise RuntimeError("BoundSecureOpenAI has been unbound. Create a new binding with bind_to_user().")
708
+ return self._user_client
709
+
710
+ def unbind(self):
711
+ """
712
+ Unbind this wrapper, invalidating all future calls.
713
+
714
+ After unbinding:
715
+ - All API calls will raise RuntimeError
716
+ - References to service and user_client are cleared
717
+ - A new binding must be created via bind_to_user()
718
+
719
+ This is useful for cleanup and ensuring bindings are not reused
720
+ after a user session ends.
721
+ """
722
+ if not self._bound:
723
+ logger.warning("BoundSecureOpenAI.unbind() called on already unbound instance")
724
+ return
725
+
726
+ logger.info(f"Unbinding SecureOpenAI from user {self._user_client.agent_id}")
727
+ self._bound = False
728
+ self._service = None
729
+ self._user_client = None
730
+
731
+ @property
732
+ def is_bound(self) -> bool:
733
+ """Check if this wrapper is still bound."""
734
+ return self._bound
735
+
736
+ # =========================================================================
737
+ # Pass-through properties for non-MACAW-protected APIs
738
+ # These delegate to the service's underlying OpenAI client
739
+ # =========================================================================
740
+
741
+ @property
742
+ def models(self):
743
+ """Pass-through to OpenAI models API via service."""
744
+ return self.service.openai_client.models
745
+
746
+ @property
747
+ def images(self):
748
+ """Pass-through to OpenAI images API (DALL-E) via service."""
749
+ return self.service.openai_client.images
750
+
751
+ @property
752
+ def audio(self):
753
+ """Pass-through to OpenAI audio API (Whisper/TTS) via service."""
754
+ return self.service.openai_client.audio
755
+
756
+ @property
757
+ def files(self):
758
+ """Pass-through to OpenAI files API via service."""
759
+ return self.service.openai_client.files
760
+
761
+ @property
762
+ def fine_tuning(self):
763
+ """Pass-through to OpenAI fine-tuning API via service."""
764
+ return self.service.openai_client.fine_tuning
765
+
766
+ @property
767
+ def moderations(self):
768
+ """Pass-through to OpenAI moderations API via service."""
769
+ return self.service.openai_client.moderations
770
+
771
+ @property
772
+ def batches(self):
773
+ """Pass-through to OpenAI batches API via service."""
774
+ return self.service.openai_client.batches
775
+
776
+ @property
777
+ def beta(self):
778
+ """Pass-through to OpenAI beta API (assistants, threads, vector stores) via service."""
779
+ return self.service.openai_client.beta
780
+
781
+ class _ChatNamespace:
782
+ def __init__(self, bound: 'BoundSecureOpenAI'):
783
+ self.bound = bound
784
+ self.completions = self._Completions(bound)
785
+
786
+ class _Completions:
787
+ def __init__(self, bound: 'BoundSecureOpenAI'):
788
+ self.bound = bound
789
+
790
+ def create(self, **kwargs):
791
+ """
792
+ Create chat completion via user's client → LLM service.
793
+
794
+ Routes to server_id (which is _llm_client.agent_id).
795
+ Authenticated prompts are auto-created by invoke_tool.
796
+ Supports streaming with stream=True parameter.
797
+ """
798
+ tool_name = f"tool:{self.bound.service.app_name}/generate"
799
+ is_streaming = kwargs.get('stream', False)
800
+
801
+ # Route through user's client to LLM service
802
+ result = self.bound.user_client.invoke_tool(
803
+ tool_name=tool_name,
804
+ parameters=kwargs,
805
+ target_agent=self.bound.service.server_id, # = _llm_client.agent_id
806
+ stream=is_streaming
807
+ )
808
+
809
+ if is_streaming:
810
+ # Streaming mode: result is a StreamingResultIterator, wrap in OpenAI types
811
+ return self._wrap_streaming_response(result)
812
+
813
+ # Handle errors
814
+ if isinstance(result, dict) and 'error' in result:
815
+ raise Exception(f"MACAW error: {result['error']}")
816
+
817
+ # Reconstruct ChatCompletion from dict
818
+ from openai.types.chat import ChatCompletion
819
+ return ChatCompletion(**result)
820
+
821
+ def _wrap_streaming_response(self, iterator):
822
+ """Wrap streaming chunks in OpenAI ChatCompletionChunk objects."""
823
+ from openai.types.chat import ChatCompletionChunk
824
+
825
+ for chunk in iterator:
826
+ if isinstance(chunk, dict):
827
+ if 'error' in chunk:
828
+ raise Exception(f"MACAW streaming error: {chunk['error']}")
829
+ # Convert dict to ChatCompletionChunk
830
+ try:
831
+ yield ChatCompletionChunk(**chunk)
832
+ except Exception as e:
833
+ logger.warning(f"Could not convert chunk to ChatCompletionChunk: {e}")
834
+ yield chunk # Return raw dict if conversion fails
835
+ else:
836
+ yield chunk
837
+
838
+ class _CompletionsNamespace:
839
+ def __init__(self, bound: 'BoundSecureOpenAI'):
840
+ self.bound = bound
841
+
842
+ def create(self, **kwargs):
843
+ """Create text completion via user's client → LLM service."""
844
+ tool_name = f"tool:{self.bound.service.app_name}/complete"
845
+
846
+ result = self.bound.user_client.invoke_tool(
847
+ tool_name=tool_name,
848
+ parameters=kwargs,
849
+ target_agent=self.bound.service.server_id # = _llm_client.agent_id
850
+ )
851
+
852
+ if isinstance(result, dict) and 'error' in result:
853
+ raise Exception(f"MACAW error: {result['error']}")
854
+
855
+ from openai.types import Completion
856
+ return Completion(**result)
857
+
858
+ class _EmbeddingsNamespace:
859
+ def __init__(self, bound: 'BoundSecureOpenAI'):
860
+ self.bound = bound
861
+
862
+ def create(self, **kwargs):
863
+ """Create embeddings via user's client → LLM service."""
864
+ tool_name = f"tool:{self.bound.service.app_name}/embed"
865
+
866
+ result = self.bound.user_client.invoke_tool(
867
+ tool_name=tool_name,
868
+ parameters=kwargs,
869
+ target_agent=self.bound.service.server_id # = _llm_client.agent_id
870
+ )
871
+
872
+ if isinstance(result, dict) and 'error' in result:
873
+ raise Exception(f"MACAW error: {result['error']}")
874
+
875
+ from openai.types import CreateEmbeddingResponse
876
+ return CreateEmbeddingResponse(**result)