sfdc-agentforce-byoc-python-sdk 0.1.0__py3-none-any.whl
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.
- agentforce_byoc/__init__.py +26 -0
- agentforce_byoc/_http.py +142 -0
- agentforce_byoc/cli/__init__.py +8 -0
- agentforce_byoc/cli/commands.py +913 -0
- agentforce_byoc/cli/main.py +44 -0
- agentforce_byoc/client.py +224 -0
- agentforce_byoc/deploy_client.py +246 -0
- agentforce_byoc/gateway/__init__.py +26 -0
- agentforce_byoc/gateway/base.py +66 -0
- agentforce_byoc/gateway/mock_relay_client.py +54 -0
- agentforce_byoc/gateway/relay_client.py +394 -0
- agentforce_byoc/logging/__init__.py +20 -0
- agentforce_byoc/logging/formatter.py +96 -0
- agentforce_byoc/logging/logger.py +59 -0
- agentforce_byoc/runtime/__init__.py +21 -0
- agentforce_byoc/runtime/agentforce_runtime.py +47 -0
- agentforce_byoc/runtime/base.py +54 -0
- agentforce_byoc/runtime/mock_runtime.py +50 -0
- agentforce_byoc/schema_utils/__init__.py +35 -0
- agentforce_byoc/schema_utils/entry_func.py +48 -0
- agentforce_byoc/schema_utils/generator.py +694 -0
- agentforce_byoc/schema_utils/schema.py +79 -0
- agentforce_byoc/templates/__init__.py +4 -0
- agentforce_byoc/templates/agent/.dockerignore.template +51 -0
- agentforce_byoc/templates/agent/.gitignore.template +64 -0
- agentforce_byoc/templates/agent/.python-version.template +1 -0
- agentforce_byoc/templates/agent/Dockerfile.template +41 -0
- agentforce_byoc/templates/agent/Makefile.template +51 -0
- agentforce_byoc/templates/agent/README.md.template +358 -0
- agentforce_byoc/templates/agent/config.json.template +3 -0
- agentforce_byoc/templates/agent/entry_func.py.template +51 -0
- agentforce_byoc/templates/agent/main.py.template +149 -0
- agentforce_byoc/templates/agent/pyproject.toml.template +24 -0
- agentforce_byoc/templates/agent/schema.py.template +73 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/METADATA +439 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/RECORD +39 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/entry_points.txt +3 -0
- sfdc_agentforce_byoc_python_sdk-0.1.0.dist-info/licenses/LICENSE +206 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Main CLI entry point for Agentforce BYOC SDK."""
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from agentforce_byoc import __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
@click.version_option(version=__version__)
|
|
13
|
+
def cli():
|
|
14
|
+
"""
|
|
15
|
+
Agentforce BYOC SDK - Build and deploy custom agents.
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
init Initialize a new agent project
|
|
19
|
+
run Run agent locally against the Agentforce runtime
|
|
20
|
+
validate Validate agent before deployment
|
|
21
|
+
zip Create a deployment-ready package archive
|
|
22
|
+
package Build end-to-end: validate -> run -> zip
|
|
23
|
+
deploy Register + upload a packaged agent to Agentforce BYOC
|
|
24
|
+
deploy-status Poll deployment status (read-only)
|
|
25
|
+
generate-schema Generate an OpenAPI spec from @entry_func functions
|
|
26
|
+
|
|
27
|
+
Auth/identity for run + deploy + deploy-status come from env vars (required;
|
|
28
|
+
abort if unset): ORG_JWT_TOKEN, SFDC_CORE_TENANT_ID
|
|
29
|
+
(core/<instance>-<fd>/<org-id>), SFDC_USER_ID, SFDC_APP_CONTEXT.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
agentforce init my-agent
|
|
33
|
+
agentforce run main.py
|
|
34
|
+
agentforce zip
|
|
35
|
+
agentforce package
|
|
36
|
+
export ORG_JWT_TOKEN=<jwt> && export SFDC_CORE_TENANT_ID=core/<inst>-<fd>/<org> && export SFDC_USER_ID=<user-id> && export SFDC_APP_CONTEXT=<app-context>
|
|
37
|
+
agentforce deploy --package-name test --package-file test-0.1.0.tar.gz
|
|
38
|
+
agentforce deploy-status --package-name test --version 1.0
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Import commands to register them with the CLI group
|
|
44
|
+
from agentforce_byoc.cli import commands # noqa: E402, F401
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Client for Agentforce BYOC SDK."""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
from agentforce_byoc.logging import get_logger
|
|
22
|
+
from agentforce_byoc.runtime.base import BaseRuntimeProvider
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_mock_mode() -> bool:
|
|
28
|
+
"""Return True if mock mode is enabled via AGENTFORCE_BYOC_MOCK."""
|
|
29
|
+
return os.getenv("AGENTFORCE_BYOC_MOCK", "").strip().lower() in (
|
|
30
|
+
"1",
|
|
31
|
+
"true",
|
|
32
|
+
"yes",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _select_runtime_provider() -> BaseRuntimeProvider:
|
|
37
|
+
"""
|
|
38
|
+
Select the runtime provider.
|
|
39
|
+
|
|
40
|
+
Mock mode (AGENTFORCE_BYOC_MOCK truthy) uses the mock provider; otherwise
|
|
41
|
+
the Agentforce BYOC runtime is used (default).
|
|
42
|
+
"""
|
|
43
|
+
if _is_mock_mode():
|
|
44
|
+
logger.info("AGENTFORCE_BYOC_MOCK set - using MOCK runtime")
|
|
45
|
+
from agentforce_byoc.runtime.mock_runtime import MockRuntimeProvider
|
|
46
|
+
|
|
47
|
+
return MockRuntimeProvider()
|
|
48
|
+
|
|
49
|
+
from agentforce_byoc.runtime.agentforce_runtime import AgentforceRuntimeProvider
|
|
50
|
+
|
|
51
|
+
return AgentforceRuntimeProvider()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Client:
|
|
55
|
+
"""
|
|
56
|
+
Main client for interacting with Agentforce services.
|
|
57
|
+
|
|
58
|
+
The Client selects the runtime (Agentforce BYOC by default, or mock when
|
|
59
|
+
AGENTFORCE_BYOC_MOCK is set) and provides a unified interface for relaying
|
|
60
|
+
calls through the Agentforce Relay API.
|
|
61
|
+
|
|
62
|
+
Features:
|
|
63
|
+
- Runtime selection via AGENTFORCE_BYOC_MOCK env var
|
|
64
|
+
- Singleton pattern (one instance per application)
|
|
65
|
+
- Relay calls for LLM generations and chat generations
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> from agentforce_byoc import Client
|
|
69
|
+
>>> client = Client()
|
|
70
|
+
>>> response = client.call_llm_generations(
|
|
71
|
+
... parameter={"prompt": "What is AI?"},
|
|
72
|
+
... )
|
|
73
|
+
|
|
74
|
+
Note:
|
|
75
|
+
Typically you don't instantiate Client directly - use Agent base class
|
|
76
|
+
which provides self.client automatically.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
_instance: Optional["Client"] = None
|
|
80
|
+
_runtime_provider: Optional[BaseRuntimeProvider] = None
|
|
81
|
+
|
|
82
|
+
def __new__(cls, runtime_provider: Optional[BaseRuntimeProvider] = None):
|
|
83
|
+
"""
|
|
84
|
+
Create or return singleton Client instance.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
runtime_provider: Optional runtime provider (for testing/advanced use)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Client singleton instance
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If trying to change runtime provider after initialization
|
|
94
|
+
"""
|
|
95
|
+
if cls._instance is None:
|
|
96
|
+
cls._instance = super().__new__(cls)
|
|
97
|
+
|
|
98
|
+
# Select runtime if not provided
|
|
99
|
+
if runtime_provider is None:
|
|
100
|
+
cls._runtime_provider = _select_runtime_provider()
|
|
101
|
+
else:
|
|
102
|
+
cls._runtime_provider = runtime_provider
|
|
103
|
+
|
|
104
|
+
logger.info(f"Initialized Client with runtime: {cls._runtime_provider.name}")
|
|
105
|
+
|
|
106
|
+
elif runtime_provider is not None and cls._instance is not None:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
"Cannot set runtime_provider after Client is initialized. "
|
|
109
|
+
"Create a new Client by resetting the singleton first."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return cls._instance
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def reset(cls):
|
|
116
|
+
"""
|
|
117
|
+
Reset the singleton instance.
|
|
118
|
+
|
|
119
|
+
Useful for testing when you need to reinitialize with different runtime.
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> Client.reset()
|
|
123
|
+
>>> client = Client(custom_runtime_provider)
|
|
124
|
+
"""
|
|
125
|
+
cls._instance = None
|
|
126
|
+
cls._runtime_provider = None
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def runtime(self) -> BaseRuntimeProvider:
|
|
130
|
+
"""Get the current runtime provider."""
|
|
131
|
+
if self._runtime_provider is None:
|
|
132
|
+
raise RuntimeError("Client is not initialized with a runtime provider.")
|
|
133
|
+
return self._runtime_provider
|
|
134
|
+
|
|
135
|
+
def call_llm_generations(self, parameter: Dict[str, Any]) -> Dict[str, Any]:
|
|
136
|
+
"""
|
|
137
|
+
Relay an LLM generations call through the Agentforce Relay API.
|
|
138
|
+
|
|
139
|
+
The tenant id, user id, and app context are resolved from the
|
|
140
|
+
environment (``SFDC_CORE_TENANT_ID`` / ``SFDC_USER_ID`` /
|
|
141
|
+
``SFDC_APP_CONTEXT``) injected into the managed sandbox.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
parameter: Raw request body, passed through to the Relay API as-is.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
The Relay API response envelope (``{body, requestId, traceId}``).
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
RuntimeError: If the Relay API call fails, or the tenant id / user
|
|
151
|
+
id / app context environment variables are not set.
|
|
152
|
+
ValueError: If the tenant id cannot be resolved to an endpoint.
|
|
153
|
+
"""
|
|
154
|
+
provider = self.runtime
|
|
155
|
+
logger.debug(
|
|
156
|
+
"Calling Relay (llm)",
|
|
157
|
+
extra={"runtime": provider.name},
|
|
158
|
+
)
|
|
159
|
+
return provider.relay_gateway_client.call_llm_generations(parameter=parameter)
|
|
160
|
+
|
|
161
|
+
def call_llm_chat_generations(self, parameter: Dict[str, Any]) -> Dict[str, Any]:
|
|
162
|
+
"""
|
|
163
|
+
Relay an LLM chat generations call through the Agentforce Relay API.
|
|
164
|
+
|
|
165
|
+
The tenant id, user id, and app context are resolved from the
|
|
166
|
+
environment (``SFDC_CORE_TENANT_ID`` / ``SFDC_USER_ID`` /
|
|
167
|
+
``SFDC_APP_CONTEXT``) injected into the managed sandbox.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
parameter: Raw request body, passed through to the Relay API as-is.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
The Relay API response envelope (``{body, requestId, traceId}``).
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
RuntimeError: If the Relay API call fails, or the tenant id / user
|
|
177
|
+
id / app context environment variables are not set.
|
|
178
|
+
ValueError: If the tenant id cannot be resolved to an endpoint.
|
|
179
|
+
"""
|
|
180
|
+
provider = self.runtime
|
|
181
|
+
logger.debug(
|
|
182
|
+
"Calling Relay (llm_chat)",
|
|
183
|
+
extra={"runtime": provider.name},
|
|
184
|
+
)
|
|
185
|
+
return provider.relay_gateway_client.call_llm_chat_generations(parameter=parameter)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ============================================================================
|
|
189
|
+
# Functional API
|
|
190
|
+
# ============================================================================
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_client(runtime_provider: Optional[BaseRuntimeProvider] = None) -> Client:
|
|
194
|
+
"""
|
|
195
|
+
Get the singleton Client instance (functional API).
|
|
196
|
+
|
|
197
|
+
This is the recommended way to access the client in functional-style code.
|
|
198
|
+
The client selects the runtime on first call.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
runtime_provider: Optional runtime provider (for testing/advanced use)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Client singleton instance
|
|
205
|
+
|
|
206
|
+
Example:
|
|
207
|
+
>>> from agentforce_byoc import get_client
|
|
208
|
+
>>>
|
|
209
|
+
>>> def function(request: dict) -> dict:
|
|
210
|
+
... client = get_client()
|
|
211
|
+
... response = client.call_llm_generations(
|
|
212
|
+
... parameter={"prompt": request.get("text", "")},
|
|
213
|
+
... )
|
|
214
|
+
... return {"response": response}
|
|
215
|
+
|
|
216
|
+
Runtime Selection:
|
|
217
|
+
- Agentforce BYOC runtime (default)
|
|
218
|
+
- Mock mode: AGENTFORCE_BYOC_MOCK is set (truthy)
|
|
219
|
+
|
|
220
|
+
Note:
|
|
221
|
+
The client is a singleton - calling get_client() multiple times
|
|
222
|
+
returns the same instance.
|
|
223
|
+
"""
|
|
224
|
+
return Client(runtime_provider=runtime_provider)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""HTTP client for the Agentforce BYOC deploy/upload API.
|
|
17
|
+
|
|
18
|
+
Implements the two-step package upload against the Agentforce BYOC platform:
|
|
19
|
+
|
|
20
|
+
1. ``POST <base>/byoc/upload/request`` to register package metadata AND obtain a
|
|
21
|
+
presigned upload URL in one call. The body carries ``packageName``,
|
|
22
|
+
``version``, ``packageSize``, ``entryPoints``, and ``maxLiveDurationS``; the
|
|
23
|
+
response returns the presigned ``uploadUrl``.
|
|
24
|
+
2. ``PUT`` the package archive to that presigned URL.
|
|
25
|
+
|
|
26
|
+
Deployment status can then be polled read-only via
|
|
27
|
+
``GET <base>/byoc/upload/status?packageName=&version=``, which reports
|
|
28
|
+
``registrationStatus`` (is the package registered) and ``uploadStatus`` (has the
|
|
29
|
+
archive been uploaded). That endpoint never writes — registration happens in
|
|
30
|
+
step 1.
|
|
31
|
+
|
|
32
|
+
The base URL is resolved by
|
|
33
|
+
:func:`agentforce_byoc.gateway.relay_client.resolve_sfap_base_url` — from the
|
|
34
|
+
``BYOC_PROXY_URL`` environment variable when set, else the production default.
|
|
35
|
+
Auth is sent as ``Authorization: Bearer <org-jwt>`` plus the
|
|
36
|
+
``x-sfdc-core-tenant-id``, ``x-sfdc-user-id``, and ``x-sfdc-app-context``
|
|
37
|
+
headers.
|
|
38
|
+
|
|
39
|
+
Uses only the Python standard library so nothing extra is bundled into a BYOC
|
|
40
|
+
package.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import urllib.parse
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any, Dict, List, cast
|
|
47
|
+
|
|
48
|
+
from agentforce_byoc import _http
|
|
49
|
+
from agentforce_byoc.logging import get_logger
|
|
50
|
+
|
|
51
|
+
logger = get_logger(__name__)
|
|
52
|
+
|
|
53
|
+
# The presigned URL is signed for ContentType "application/gzip". The PUT must
|
|
54
|
+
# send exactly this Content-Type or the upload is rejected as a signature
|
|
55
|
+
# mismatch.
|
|
56
|
+
_PACKAGE_CONTENT_TYPE = "application/gzip"
|
|
57
|
+
|
|
58
|
+
# Timeout (seconds) for API calls and the package upload PUT.
|
|
59
|
+
_DEPLOY_TIMEOUT_S = 120
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _post_json(
|
|
63
|
+
url: str,
|
|
64
|
+
body: Dict[str, Any],
|
|
65
|
+
org_jwt: str,
|
|
66
|
+
tenant_id: str,
|
|
67
|
+
user_id: str,
|
|
68
|
+
app_context: str,
|
|
69
|
+
) -> Dict[str, Any]:
|
|
70
|
+
"""
|
|
71
|
+
POST a JSON body to a BYOC API endpoint and parse the response.
|
|
72
|
+
|
|
73
|
+
Sends the org JWT as a bearer token plus the tenant id, user id, and app
|
|
74
|
+
context as the ``x-sfdc-core-tenant-id`` / ``x-sfdc-user-id`` /
|
|
75
|
+
``x-sfdc-app-context`` headers.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
RuntimeError: On HTTP error, transport error, or a non-JSON response.
|
|
79
|
+
"""
|
|
80
|
+
# The org JWT is sent as a bearer token; the tenant id also travels as the
|
|
81
|
+
# x-sfdc-core-tenant-id header. A 400 "x-sfdc-core-tenant-id header is
|
|
82
|
+
# required" indicates the JWT was rejected, not a missing header here. The
|
|
83
|
+
# user id and app context identify who is deploying, matching the relay path.
|
|
84
|
+
headers = {
|
|
85
|
+
"Authorization": f"Bearer {org_jwt}",
|
|
86
|
+
"x-sfdc-core-tenant-id": tenant_id,
|
|
87
|
+
"x-sfdc-user-id": user_id,
|
|
88
|
+
"x-sfdc-app-context": app_context,
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
status, raw = _http.post_json(url, body, headers, timeout=_DEPLOY_TIMEOUT_S)
|
|
92
|
+
except OSError as e:
|
|
93
|
+
raise RuntimeError(f"BYOC API call to {url} failed: {e}") from e
|
|
94
|
+
|
|
95
|
+
# Surface the BYOC API response (status + body) so callers can see exactly
|
|
96
|
+
# what the platform returned.
|
|
97
|
+
logger.info("BYOC API %s -> status=%s body=%s", url, status, raw)
|
|
98
|
+
|
|
99
|
+
if status >= 400:
|
|
100
|
+
raise RuntimeError(f"BYOC API call to {url} failed with status {status}: {raw}")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
return cast(Dict[str, Any], json.loads(raw))
|
|
104
|
+
except json.JSONDecodeError as e:
|
|
105
|
+
raise RuntimeError(f"BYOC API at {url} returned a non-JSON response: {raw!r}") from e
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _get_json(
|
|
109
|
+
url: str,
|
|
110
|
+
org_jwt: str,
|
|
111
|
+
tenant_id: str,
|
|
112
|
+
user_id: str,
|
|
113
|
+
app_context: str,
|
|
114
|
+
) -> Dict[str, Any]:
|
|
115
|
+
"""
|
|
116
|
+
GET a BYOC API endpoint and parse the JSON response.
|
|
117
|
+
|
|
118
|
+
Sends the same identity headers as :func:`_post_json`. Used for the
|
|
119
|
+
read-only ``GET /byoc/upload/status`` poll, which takes its parameters in
|
|
120
|
+
the query string and has no request body.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
RuntimeError: On HTTP error, transport error, or a non-JSON response.
|
|
124
|
+
"""
|
|
125
|
+
headers = {
|
|
126
|
+
"Authorization": f"Bearer {org_jwt}",
|
|
127
|
+
"x-sfdc-core-tenant-id": tenant_id,
|
|
128
|
+
"x-sfdc-user-id": user_id,
|
|
129
|
+
"x-sfdc-app-context": app_context,
|
|
130
|
+
}
|
|
131
|
+
try:
|
|
132
|
+
status, raw = _http.request("GET", url, headers=headers, timeout=_DEPLOY_TIMEOUT_S)
|
|
133
|
+
except OSError as e:
|
|
134
|
+
raise RuntimeError(f"BYOC API call to {url} failed: {e}") from e
|
|
135
|
+
|
|
136
|
+
body_text = raw.decode("utf-8", errors="replace")
|
|
137
|
+
logger.info("BYOC API %s -> status=%s body=%s", url, status, body_text)
|
|
138
|
+
|
|
139
|
+
if status >= 400:
|
|
140
|
+
raise RuntimeError(f"BYOC API call to {url} failed with status {status}: {body_text}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
return cast(Dict[str, Any], json.loads(body_text))
|
|
144
|
+
except json.JSONDecodeError as e:
|
|
145
|
+
raise RuntimeError(f"BYOC API at {url} returned a non-JSON response: {body_text!r}") from e
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _put_file(url: str, file_path: Path, content_type: str) -> None:
|
|
149
|
+
"""
|
|
150
|
+
PUT a file's bytes to a (presigned) URL.
|
|
151
|
+
|
|
152
|
+
No auth header is added: the presigned URL carries its own signature.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
RuntimeError: On HTTP error or transport error.
|
|
156
|
+
"""
|
|
157
|
+
data = file_path.read_bytes()
|
|
158
|
+
try:
|
|
159
|
+
status, raw = _http.request(
|
|
160
|
+
"PUT",
|
|
161
|
+
url,
|
|
162
|
+
headers={"Content-Type": content_type},
|
|
163
|
+
body=data,
|
|
164
|
+
timeout=_DEPLOY_TIMEOUT_S,
|
|
165
|
+
)
|
|
166
|
+
except OSError as e:
|
|
167
|
+
raise RuntimeError(f"Upload to presigned URL failed: {e}") from e
|
|
168
|
+
|
|
169
|
+
if status >= 400:
|
|
170
|
+
body_text = raw.decode("utf-8", errors="replace")
|
|
171
|
+
raise RuntimeError(f"Upload to presigned URL failed with status {status}: {body_text}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def request_presigned_upload(
|
|
175
|
+
base_url: str,
|
|
176
|
+
tenant_id: str,
|
|
177
|
+
org_jwt: str,
|
|
178
|
+
user_id: str,
|
|
179
|
+
app_context: str,
|
|
180
|
+
package_name: str,
|
|
181
|
+
version: str,
|
|
182
|
+
package_size: int,
|
|
183
|
+
entry_points: List[str],
|
|
184
|
+
max_live_duration_s: int,
|
|
185
|
+
) -> Dict[str, Any]:
|
|
186
|
+
"""
|
|
187
|
+
Step 1: register package metadata and get a presigned upload URL.
|
|
188
|
+
|
|
189
|
+
``POST /byoc/upload/request`` does both in one call: it registers the
|
|
190
|
+
package metadata (``packageSize``, ``entryPoints``, ``maxLiveDurationS``) and
|
|
191
|
+
returns the presigned ``uploadUrl``. The caller then PUTs the archive to that
|
|
192
|
+
URL (see :func:`upload_package`).
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The response ``{uploadUrl, s3Key, tenantId, packageName, version,
|
|
196
|
+
uploadTime, packageSize, entryPoints, maxLiveDurationS, status}``.
|
|
197
|
+
"""
|
|
198
|
+
url = f"{base_url}/byoc/upload/request"
|
|
199
|
+
body = {
|
|
200
|
+
"packageName": package_name,
|
|
201
|
+
"version": version,
|
|
202
|
+
"packageSize": package_size,
|
|
203
|
+
"entryPoints": entry_points,
|
|
204
|
+
"maxLiveDurationS": max_live_duration_s,
|
|
205
|
+
}
|
|
206
|
+
logger.info("Registering package + requesting presigned upload URL from %s", url)
|
|
207
|
+
return _post_json(url, body, org_jwt, tenant_id, user_id, app_context)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def upload_package(upload_url: str, file_path: Path) -> None:
|
|
211
|
+
"""Step 2: upload the package archive to the presigned URL."""
|
|
212
|
+
logger.info("Uploading %s (%d bytes)", file_path.name, file_path.stat().st_size)
|
|
213
|
+
_put_file(upload_url, file_path, _PACKAGE_CONTENT_TYPE)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_upload_status(
|
|
217
|
+
base_url: str,
|
|
218
|
+
tenant_id: str,
|
|
219
|
+
org_jwt: str,
|
|
220
|
+
user_id: str,
|
|
221
|
+
app_context: str,
|
|
222
|
+
package_name: str,
|
|
223
|
+
version: str,
|
|
224
|
+
) -> Dict[str, Any]:
|
|
225
|
+
"""
|
|
226
|
+
Poll deployment status (read-only).
|
|
227
|
+
|
|
228
|
+
Calls ``GET /byoc/upload/status`` with ``packageName`` and ``version`` in the
|
|
229
|
+
query string. The endpoint never writes — registration happens in
|
|
230
|
+
:func:`request_presigned_upload`. It reports both halves of the two-phase
|
|
231
|
+
upload independently:
|
|
232
|
+
|
|
233
|
+
- ``registrationStatus``: ``"registered"`` if the package metadata exists,
|
|
234
|
+
else ``"unregistered"``.
|
|
235
|
+
- ``uploadStatus``: ``"uploaded"`` if the archive has been uploaded,
|
|
236
|
+
``"pending"`` if not, ``"unknown"`` if the check itself failed.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
``{tenantId, packageName, version, s3Key, registrationStatus,
|
|
240
|
+
uploadStatus, ...}`` (plus ``uploadTime``/``packageSize``/``entryPoints``/
|
|
241
|
+
``maxLiveDurationS`` when registered).
|
|
242
|
+
"""
|
|
243
|
+
query = urllib.parse.urlencode({"packageName": package_name, "version": version})
|
|
244
|
+
url = f"{base_url}/byoc/upload/status?{query}"
|
|
245
|
+
logger.info("Polling upload status from %s", url)
|
|
246
|
+
return _get_json(url, org_jwt, tenant_id, user_id, app_context)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Gateway clients for external services."""
|
|
17
|
+
|
|
18
|
+
from agentforce_byoc.gateway.base import BaseRelayGatewayClient
|
|
19
|
+
from agentforce_byoc.gateway.mock_relay_client import MockRelayGatewayClient
|
|
20
|
+
from agentforce_byoc.gateway.relay_client import AgentforceRelayGatewayClient
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BaseRelayGatewayClient",
|
|
24
|
+
"AgentforceRelayGatewayClient",
|
|
25
|
+
"MockRelayGatewayClient",
|
|
26
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Base class for relay gateway clients."""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseRelayGatewayClient(ABC):
|
|
23
|
+
"""
|
|
24
|
+
Abstract base class for Relay gateway clients.
|
|
25
|
+
|
|
26
|
+
The Relay gateway routes API calls from BYOC code (running in the managed
|
|
27
|
+
sandbox, which has no direct outbound network) through the Agentforce Relay
|
|
28
|
+
API. Each relay function is exposed as a method.
|
|
29
|
+
|
|
30
|
+
Implementations must relay the raw ``parameter`` body through to the
|
|
31
|
+
Relay API unchanged.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def call_llm_generations(self, parameter: Dict[str, Any]) -> Dict[str, Any]:
|
|
36
|
+
"""
|
|
37
|
+
Relay an LLM generations call (Relay function ``llm``).
|
|
38
|
+
|
|
39
|
+
The tenant id, user id, and app context are resolved from the
|
|
40
|
+
environment (``SFDC_CORE_TENANT_ID`` / ``SFDC_USER_ID`` /
|
|
41
|
+
``SFDC_APP_CONTEXT``) injected into the managed sandbox.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
parameter: Raw request body, passed through to the Relay API as-is.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The Relay API response envelope (``{body, requestId, traceId}``).
|
|
48
|
+
"""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def call_llm_chat_generations(self, parameter: Dict[str, Any]) -> Dict[str, Any]:
|
|
53
|
+
"""
|
|
54
|
+
Relay an LLM chat generations call (Relay function ``llm_chat``).
|
|
55
|
+
|
|
56
|
+
The tenant id, user id, and app context are resolved from the
|
|
57
|
+
environment (``SFDC_CORE_TENANT_ID`` / ``SFDC_USER_ID`` /
|
|
58
|
+
``SFDC_APP_CONTEXT``) injected into the managed sandbox.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
parameter: Raw request body, passed through to the Relay API as-is.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
The Relay API response envelope (``{body, requestId, traceId}``).
|
|
65
|
+
"""
|
|
66
|
+
...
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2026, Salesforce, Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Mock Relay gateway client for local development."""
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from agentforce_byoc.gateway.relay_client import ORG_JWT_ENV_VAR, AgentforceRelayGatewayClient
|
|
22
|
+
from agentforce_byoc.logging import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MockRelayGatewayClient(AgentforceRelayGatewayClient):
|
|
28
|
+
"""
|
|
29
|
+
Relay gateway client for local development outside the managed sandbox.
|
|
30
|
+
|
|
31
|
+
Behaves exactly like :class:`AgentforceRelayGatewayClient` — same URL
|
|
32
|
+
resolution, request shape, and transport — except the org JWT is supplied
|
|
33
|
+
by the SDK author rather than injected by the runtime. The JWT is taken
|
|
34
|
+
from the ``org_jwt`` constructor argument, falling back to the
|
|
35
|
+
``ORG_JWT_TOKEN`` environment variable.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, org_jwt: Optional[str] = None):
|
|
39
|
+
self._org_jwt = org_jwt
|
|
40
|
+
|
|
41
|
+
def _get_org_jwt(self) -> str:
|
|
42
|
+
"""
|
|
43
|
+
Resolve the org JWT from the constructor arg or ``ORG_JWT_TOKEN``.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
RuntimeError: If neither source provides a JWT.
|
|
47
|
+
"""
|
|
48
|
+
org_jwt = self._org_jwt or os.getenv(ORG_JWT_ENV_VAR)
|
|
49
|
+
if not org_jwt:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
f"No org JWT supplied for mock mode. Pass org_jwt=... or "
|
|
52
|
+
f"export {ORG_JWT_ENV_VAR}."
|
|
53
|
+
)
|
|
54
|
+
return org_jwt
|